From 34fef44222ded7674a441f73e13c5fc0fdd5a6bc Mon Sep 17 00:00:00 2001 From: Bjorn Lammers Date: Thu, 24 Apr 2025 15:47:37 +0200 Subject: [PATCH] feat(web): Add structured data and improve SEO metadata --- web/public/robots.txt | 6 + web/src/app/icons/[icon]/opengraph-image.tsx | 51 ++++---- web/src/app/icons/[icon]/page.tsx | 70 ++++++----- web/src/app/icons/page.tsx | 87 ++++++------- web/src/app/layout.tsx | 62 ++++++---- web/src/app/page.tsx | 42 +++---- web/src/app/robots.txt | 3 - web/src/components/structured-data.tsx | 31 +++++ web/src/constants.ts | 121 ++++++++++++++++++- 9 files changed, 327 insertions(+), 146 deletions(-) create mode 100644 web/public/robots.txt delete mode 100644 web/src/app/robots.txt create mode 100644 web/src/components/structured-data.tsx diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 00000000..17c23e78 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,6 @@ +# Allow all user agents +User-agent: * +Allow: / + +# Sitemap location (adjust if needed) +Sitemap: https://dashboardicons.com/sitemap.xml \ No newline at end of file diff --git a/web/src/app/icons/[icon]/opengraph-image.tsx b/web/src/app/icons/[icon]/opengraph-image.tsx index 03e2ab49..82d62c5f 100644 --- a/web/src/app/icons/[icon]/opengraph-image.tsx +++ b/web/src/app/icons/[icon]/opengraph-image.tsx @@ -2,6 +2,12 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" import { getAllIcons } from "@/lib/api" import { ImageResponse } from "next/og" +import { + SITE_NAME, + SITE_TAGLINE, + getIconDescription, + WEB_URL +} from "@/constants" export const dynamic = "force-static" @@ -32,10 +38,9 @@ export default async function Image({ params }: { params: { icon: string } }) { let iconData: Buffer | null = null try { 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) } catch (error) { - console.error(`Icon ${icon} was not found locally`) + // Icon file might not be found, fallback handled below } // Convert the image data to a data URL or use placeholder @@ -52,9 +57,9 @@ export default async function Image({ params }: { params: { icon: string } }) { position: "relative", fontFamily: "Inter, system-ui, sans-serif", overflow: "hidden", - backgroundColor: "white", + backgroundColor: "#0f172a", // Dark background (slate-900) backgroundImage: - "radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)", + "radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)", backgroundSize: "100px 100px", }} > @@ -67,7 +72,7 @@ export default async function Image({ params }: { params: { icon: string } }) { width: 400, height: 400, borderRadius: "50%", - background: "linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)", + background: "linear-gradient(135deg, rgba(56, 189, 248, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)", filter: "blur(80px)", zIndex: 2, }} @@ -80,7 +85,7 @@ export default async function Image({ params }: { params: { icon: string } }) { width: 500, height: 500, borderRadius: "50%", - background: "linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(234, 88, 12, 0.1) 100%)", + background: "linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(234, 88, 12, 0.15) 100%)", filter: "blur(100px)", zIndex: 2, }} @@ -109,8 +114,8 @@ export default async function Image({ params }: { params: { icon: string } }) { width: 320, height: 320, borderRadius: 32, - background: "white", - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)", + background: "#1e293b", // Dark container (slate-800) + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)", padding: 30, flexShrink: 0, position: "relative", @@ -121,7 +126,7 @@ export default async function Image({ params }: { params: { icon: string } }) { style={{ position: "absolute", inset: 0, - background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)", + background: "linear-gradient(145deg, #1e293b 0%, #0f172a 100%)", zIndex: 0, }} /> @@ -134,7 +139,7 @@ export default async function Image({ params }: { params: { icon: string } }) { objectFit: "contain", position: "relative", zIndex: 1, - filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1))", + filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.3))", }} /> @@ -154,7 +159,7 @@ export default async function Image({ params }: { params: { icon: string } }) { display: "flex", fontSize: 64, fontWeight: 800, - color: "#0f172a", + color: "#f8fafc", // Light text for dark background (slate-50) lineHeight: 1.1, letterSpacing: "-0.02em", }} @@ -167,14 +172,14 @@ export default async function Image({ params }: { params: { icon: string } }) { display: "flex", fontSize: 32, fontWeight: 500, - color: "#64748b", + color: "#94a3b8", // Muted text (slate-400) lineHeight: 1.4, position: "relative", paddingLeft: 16, - borderLeft: "4px solid #94a3b8", + borderLeft: "4px solid #64748b", // slate-500 }} > - Amongst {totalIcons} other high-quality dashboard icons + {getIconDescription(formattedIconName, totalIcons)}
{format} @@ -219,8 +224,8 @@ export default async function Image({ params }: { params: { icon: string } }) { display: "flex", alignItems: "center", justifyContent: "center", - background: "#ffffff", - borderTop: "2px solid rgba(0, 0, 0, 0.05)", + background: "#1e293b", // slate-800 + borderTop: "2px solid rgba(255, 255, 255, 0.1)", zIndex: 20, }} > @@ -229,7 +234,7 @@ export default async function Image({ params }: { params: { icon: string } }) { display: "flex", fontSize: 24, fontWeight: 600, - color: "#334155", + color: "#e2e8f0", // slate-200 alignItems: "center", gap: 10, }} @@ -239,11 +244,11 @@ export default async function Image({ params }: { params: { icon: string } }) { width: 8, height: 8, borderRadius: "50%", - backgroundColor: "#3b82f6", + backgroundColor: "#3b82f6", // blue-500 marginRight: 4, }} /> - dashboardicons.com + {WEB_URL.replace("https://", "")}
, diff --git a/web/src/app/icons/[icon]/page.tsx b/web/src/app/icons/[icon]/page.tsx index 186ca02f..ce3a50bd 100644 --- a/web/src/app/icons/[icon]/page.tsx +++ b/web/src/app/icons/[icon]/page.tsx @@ -1,7 +1,9 @@ import { IconDetails } from "@/components/icon-details" -import { BASE_URL, WEB_URL } from "@/constants" +import { StructuredData } from "@/components/structured-data" +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 type { Metadata, ResolvingMetadata } from "next" +import Script from "next/script" import { notFound } from "next/navigation" export const dynamicParams = false @@ -16,12 +18,12 @@ export async function generateStaticParams() { export const dynamic = "force-static" type Props = { - params: Promise<{ icon: string }> - searchParams: Promise<{ [key: string]: string | string[] | undefined }> + params: { icon: string } + searchParams: { [key: string]: string | string[] | undefined } } export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise { - const { icon } = await params + const { icon } = params const iconsData = await getAllIcons() if (!iconsData[icon]) { notFound() @@ -31,8 +33,6 @@ export async function generateMetadata({ params, searchParams }: Props, parent: const updateDate = new Date(iconsData[icon].update.timestamp) 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 pageUrl = `${WEB_URL}/icons/${icon}` const formattedIconName = icon @@ -40,43 +40,39 @@ export async function generateMetadata({ params, searchParams }: Props, parent: .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .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 { - title: `${formattedIconName} Icon | Dashboard Icons`, - description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + title, + description, assets: [iconImageUrl], - category: "icons", - keywords: [ - `${formattedIconName} icon`, - "dashboard icon", - "service icon", - "application icon", - "tool icon", - "web dashboard", - "app directory", - ], + category: "Icons", + keywords: ICON_DETAIL_KEYWORDS(formattedIconName), icons: { icon: iconImageUrl, }, - abstract: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + abstract: description, robots: { index: true, follow: true, }, openGraph: { - title: `${formattedIconName} Icon | Dashboard Icons`, - description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + title: title, + description, type: "article", url: pageUrl, authors: [authorName], publishedTime: updateDate.toISOString(), modifiedTime: updateDate.toISOString(), section: "Icons", - tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"], + tags: [formattedIconName, ...ICON_DETAIL_KEYWORDS(formattedIconName)], }, twitter: { card: "summary_large_image", - title: `${formattedIconName} Icon | Dashboard Icons`, - description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + title: title, + description, images: [iconImageUrl], }, alternates: { @@ -90,8 +86,8 @@ export async function generateMetadata({ params, searchParams }: Props, parent: } } -export default async function IconPage({ params }: { params: Promise<{ icon: string }> }) { - const { icon } = await params +export default async function IconPage({ params }: { params: { icon: string } }) { + const { icon } = params const iconsData = await getAllIcons() const originalIconData = iconsData[icon] @@ -100,6 +96,26 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str } 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(" ") - return + const imageSchema = getIconSchema( + formattedIconName, + icon, + authorName, + authorData.html_url, + updateDate.toISOString(), + Object.keys(iconsData).length + ) + + return ( + <> + + + + ) } diff --git a/web/src/app/icons/page.tsx b/web/src/app/icons/page.tsx index a218be25..1aa7d5f5 100644 --- a/web/src/app/icons/page.tsx +++ b/web/src/app/icons/page.tsx @@ -1,48 +1,35 @@ -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 type { Metadata } from "next" +import { StructuredData } from "@/components/structured-data" import { IconSearch } from "./components/icon-search" export async function generateMetadata(): Promise { const icons = await getIconsArray() const totalIcons = icons.length + const title = `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME}` + const description = getBrowseDescription(totalIcons) + return { - title: "Browse Icons | Free Dashboard Icons", - description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, - keywords: [ - "browse icons", - "dashboard icons", - "icon search", - "service icons", - "application icons", - "tool icons", - "web dashboard", - "app directory", - ], + title, + description, + keywords: BROWSE_KEYWORDS, openGraph: { - title: "Browse Icons | Free Dashboard Icons", - description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + title: title, + description, type: "website", - url: `${BASE_URL}/icons`, - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Browse Dashboard Icons Collection", - type: "image/png", - }, - ], + url: `${WEB_URL}/icons`, + images: [DEFAULT_OG_IMAGE], }, twitter: { card: "summary_large_image", - title: "Browse Icons | Free Dashboard Icons", - description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, - images: ["/og-image-browse.png"], + title: title, + description, + images: [DEFAULT_OG_IMAGE.url], }, alternates: { - canonical: `${BASE_URL}/icons`, + canonical: `${WEB_URL}/icons`, }, } } @@ -51,20 +38,38 @@ export const dynamic = "force-static" export default async function IconsPage() { const icons = await getIconsArray() - return ( -
-
-
-
-
-

Browse icons

-

Search through our collection of {icons.length} beautiful icons.

-
-
- + 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 ( + <> + +
+
+
+
+
+

Icons

+

Search our collection of {icons.length} icons - {SITE_TAGLINE}.

+
+
+ + +
-
+ ) } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 910ef52b..f5e3dd96 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,12 +2,13 @@ import { PostHogProvider } from "@/components/PostHogProvider" import { Footer } from "@/components/footer" import { HeaderWrapper } from "@/components/header-wrapper" import { LicenseNotice } from "@/components/license-notice" +import { WebsiteStructuredData } from "@/components/structured-data" import { getTotalIcons } from "@/lib/api" import type { Metadata, Viewport } from "next" import { Inter } from "next/font/google" import { Toaster } from "sonner" import "./globals.css" -import { getDescription, websiteTitle } from "@/constants" +import { 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" const inter = Inter({ @@ -27,12 +28,16 @@ export const viewport: Viewport = { export async function generateMetadata(): Promise { const { totalIcons } = await getTotalIcons() + const description = getDescription(totalIcons) return { - metadataBase: new URL("https://dashboardicons.com"), - title: websiteTitle, - description: getDescription(totalIcons), - keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], + metadataBase: new URL(WEB_URL), + title: { + default: websiteTitle, + template: `%s | ${websiteTitle}`, + }, + description, + keywords: DEFAULT_KEYWORDS, robots: { index: true, follow: true, @@ -42,33 +47,23 @@ export async function generateMetadata(): Promise { googleBot: "index, follow", }, openGraph: { - siteName: "Dashboard Icons", + siteName: SITE_NAME, type: "website", locale: "en_US", - title: websiteTitle, - description: getDescription(totalIcons), - url: "https://dashboardicons.com", - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Dashboard Icons", - type: "image/png", - }, - ], + title: websiteFullTitle, + description, + url: WEB_URL, + images: [DEFAULT_OG_IMAGE], }, twitter: { card: "summary_large_image", - site: "@homarr_app", - creator: "@homarr_app", - title: websiteTitle, - description: getDescription(totalIcons), - images: ["/og-image.png"], + title: websiteFullTitle, + description, + images: [DEFAULT_OG_IMAGE.url], }, - applicationName: "Dashboard Icons", + applicationName: SITE_NAME, appleWebApp: { - title: "Dashboard Icons", + title: SITE_NAME, statusBarStyle: "default", capable: true, }, @@ -88,14 +83,29 @@ export async function generateMetadata(): Promise { ], }, 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 function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { +export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const { totalIcons } = await getTotalIcons() + const websiteSchema = getWebsiteSchema(totalIcons) + return ( +
{children}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 2682ff6c..70f008e1 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,42 +1,36 @@ import { HeroSection } from "@/components/hero" import { RecentlyAddedIcons } from "@/components/recently-added-icons" -import { BASE_URL, REPO_NAME, getDescription, websiteTitle } from "@/constants" +import { BASE_URL, DEFAULT_KEYWORDS, DEFAULT_OG_IMAGE, GITHUB_URL, ORGANIZATION_NAME, ORGANIZATION_SCHEMA, SITE_NAME, SITE_TAGLINE, WEB_URL, REPO_NAME, getHomeDescription, websiteFullTitle, websiteTitle } from "@/constants" import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api" import type { Metadata } from "next" export async function generateMetadata(): Promise { const { totalIcons } = await getTotalIcons() + const description = getHomeDescription(totalIcons) return { title: websiteTitle, - description: getDescription(totalIcons), - keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], + description, + keywords: DEFAULT_KEYWORDS, robots: { index: true, follow: true, }, openGraph: { - title: websiteTitle, - description: getDescription(totalIcons), + title: websiteFullTitle, + description, type: "website", - url: BASE_URL, - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - alt: "Dashboard Icons", - }, - ], + url: WEB_URL, + images: [DEFAULT_OG_IMAGE], }, twitter: { - title: websiteTitle, - description: getDescription(totalIcons), + title: websiteFullTitle, + description, card: "summary_large_image", - images: ["/og-image.png"], + images: [DEFAULT_OG_IMAGE.url], }, alternates: { - canonical: BASE_URL, + canonical: WEB_URL, }, } } @@ -44,7 +38,7 @@ export async function generateMetadata(): Promise { async function getGitHubStars() { const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`) const data = await response.json() - console.log(`GitHub stars: ${data.stargazers_count}`) + // TODO: Consider caching this result or fetching at build time to avoid rate limits. return data.stargazers_count } @@ -54,9 +48,11 @@ export default async function Home() { const stars = await getGitHubStars() return ( -
- - -
+ <> +
+ + +
+ ) } diff --git a/web/src/app/robots.txt b/web/src/app/robots.txt deleted file mode 100644 index d52321a0..00000000 --- a/web/src/app/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -User-Agent: * -Allow: / -Sitemap: https://dashboardicons.com/sitemap.xml \ No newline at end of file diff --git a/web/src/components/structured-data.tsx b/web/src/components/structured-data.tsx new file mode 100644 index 00000000..dc1593eb --- /dev/null +++ b/web/src/components/structured-data.tsx @@ -0,0 +1,31 @@ +type StructuredDataProps = { + data: any + id?: string +} + +export const StructuredData = ({ data, id }: StructuredDataProps) => { + return ( +