feat: Add website (#1157)

Co-authored-by: Bjorn Lammers <bjorn@lammers.media>
This commit is contained in:
Thomas Camlong
2025-04-16 16:18:20 +02:00
committed by GitHub
parent f46843795d
commit bfe293f090
86 changed files with 13563 additions and 1269 deletions

142
web/src/app/globals.css Normal file
View File

@@ -0,0 +1,142 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
:root {
--radius: 0.3rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.637 0.237 25.331);
--primary-foreground: oklch(0.971 0.013 17.38);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.637 0.237 25.331);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.637 0.237 25.331);
--primary-foreground: oklch(0.971 0.013 17.38);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.637 0.237 25.331);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

1
web/src/app/icon.svg Executable file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 82.9 512 346.2"><path d="M169.3 102.7c28.8 0 52.2 23.4 52.2 52.2v66.8c1.8-.6 3.6-1 5.5-1 5.9 0 11.1 2.9 14.2 7.4v-73.2c0-39.7-32.3-72-72-72-24.8 0-46.8 12.6-59.7 31.8 5.6 3.6 10.9 7.5 16 11.8 9.4-14.3 25.5-23.8 43.8-23.8m115.6 118.1c1.9 0 3.8.4 5.5 1V155c0-28.8 23.4-52.2 52.2-52.2 18.3 0 34.4 9.5 43.8 23.8 5.1-4.2 10.4-8.2 16-11.8C389.5 95.6 367.5 83 342.7 83c-39.7 0-72 32.3-72 72v73.2c3.1-4.5 8.3-7.4 14.2-7.4m-69.3 104.8c-35.7 8.6-66 28.1-88.1 54-12.2 14.3-21.9 30.6-28.6 48l46.6.3 265.5 1.2v-.1c-29.2-77.8-112.5-123.4-195.4-103.4m11.5-61.9c-9.7 0-17.5 7.8-17.5 17.5s7.8 17.5 17.5 17.5 17.5-7.8 17.5-17.5-7.8-17.5-17.5-17.5m57.8 35.1c9.7 0 17.5-7.8 17.5-17.5s-7.8-17.5-17.5-17.5-17.5 7.8-17.5 17.5 7.8 17.5 17.5 17.5m162.5-75.6 26.7-108.8c-22.6 2.8-43.5 11.1-61.5 23.4-6.3 4.3-12.3 9.2-17.8 14.4-26.4 25.4-42.9 61.1-42.9 100.6 0 30.7 9.9 59.1 26.7 82.1 9.2 12.7 20.6 23.7 33.4 32.6l9.3-37.8c47.9-14.3 84.1-55.7 90.7-106.5zM133.5 334.9c16.8-23 26.7-51.4 26.7-82.1 0-39.5-16.5-75.2-42.9-100.6-5.5-5.3-11.5-10.1-17.8-14.4-17.9-12.3-38.8-20.6-61.5-23.4l26.7 108.8H0c6.6 50.8 42.8 92.2 90.7 106.5l9.3 37.8c12.9-8.9 24.2-19.9 33.5-32.6" style="fill:#fa5252"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,99 @@
import { IconDetails } from "@/components/icon-details"
import { BASE_URL } from "@/constants"
import { getAllIcons, getAuthorData } from "@/lib/api"
import type { Metadata, ResolvingMetadata } from "next"
import { notFound } from "next/navigation"
export const dynamicParams = false
export async function generateStaticParams() {
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({
icon,
}))
}
export const dynamic = "force-static"
type Props = {
params: Promise<{ icon: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
const { icon } = await params
const iconsData = await getAllIcons()
if (!iconsData[icon]) {
notFound()
}
const previousImages = (await parent).openGraph?.images || []
const authorData = await getAuthorData(iconsData[icon].update.author.id)
const authorName = authorData.name || authorData.login
const updateDate = new Date(iconsData[icon].update.timestamp)
console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`)
const iconImageUrl = `${BASE_URL}/png/${icon}.png`
const pageUrl = `${BASE_URL}/icons/${icon}`
return {
title: `${icon} icon · Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
keywords: [`${icon} icon`, "dashboard icon", "free icon", "open source icon", "application icon"],
authors: [
{
name: "homarr",
url: "https://homarr.dev",
},
{
name: authorName,
url: authorData.html_url,
},
],
openGraph: {
title: `${icon} icon · Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
type: "article",
url: pageUrl,
images: [
{
url: iconImageUrl,
width: 512,
height: 512,
alt: `${icon} icon`,
type: "image/png",
},
...previousImages,
],
authors: [authorName, "homarr"],
publishedTime: updateDate.toISOString(),
modifiedTime: updateDate.toISOString(),
},
twitter: {
card: "summary_large_image",
title: `${icon} icon · Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
images: [iconImageUrl],
creator: "@ajnavocado",
},
alternates: {
canonical: pageUrl,
},
}
}
export default async function IconPage({ params }: { params: Promise<{ icon: string }> }) {
const { icon } = await params
const iconsData = await getAllIcons()
const originalIconData = iconsData[icon]
if (!originalIconData) {
notFound()
}
// Pass originalIconData directly, assuming IconDetails can handle it
const iconData = originalIconData
const authorData = await getAuthorData(originalIconData.update.author.id)
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
}

View File

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

View File

@@ -0,0 +1,127 @@
"use client"
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Input } from "@/components/ui/input"
import { BASE_URL } from "@/constants"
import type { IconSearchProps } from "@/types/icons"
import { Search } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
export function IconSearch({ icons }: IconSearchProps) {
const searchParams = useSearchParams()
const initialQuery = searchParams.get("q")
const router = useRouter()
const pathname = usePathname()
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const [filteredIcons, setFilteredIcons] = useState(() => {
if (!initialQuery?.trim()) return icons
const q = initialQuery.toLowerCase()
return icons.filter(({ name, data }) => {
if (name.toLowerCase().includes(q)) return true
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
return false
})
})
const filterIcons = useCallback(
(query: string) => {
if (!query.trim()) {
return icons
}
const q = query.toLowerCase()
return icons.filter(({ name, data }) => {
if (name.toLowerCase().includes(q)) return true
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
return false
})
},
[icons],
)
const updateResults = useCallback(
(query: string) => {
setFilteredIcons(filterIcons(query))
const params = new URLSearchParams()
if (query) params.set("q", query)
const newUrl = query ? `${pathname}?${params.toString()}` : pathname
router.push(newUrl, { scroll: false })
},
[filterIcons, pathname, router],
)
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
updateResults(query)
}, 100)
},
[updateResults],
)
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
if (!searchParams) return null
return (
<>
<div className="relative w-full sm:max-w-md">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search icons by name, aliases, or categories..."
className="w-full pl-8"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto">
<div className="text-center">
<h2 className="text-5xl font-semibold">We don't have this one...yet!</h2>
</div>
<IconSubmissionContent />
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-8">
{filteredIcons.map(({ name, data }) => (
<Link
prefetch={false}
key={name}
href={`/icons/${name}`}
className="group flex flex-col items-center p-3 sm:p-4 rounded-lg border border-border hover:border-primary hover:bg-accent transition-colors"
>
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
<Image
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize">{name.replace(/-/g, " ")}</span>
</Link>
))}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,54 @@
import { BASE_URL } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { Metadata } from "next"
import { IconSearch } from "./components/icon-search"
export const metadata: Metadata = {
title: "Browse icons | Dashboard Icons",
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
keywords: ["dashboard icons", "browse icons", "icon search", "free icons", "open source icons"],
openGraph: {
title: "Browse Dashboard Icons Collection",
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
type: "website",
url: `${BASE_URL}/icons`,
images: [
{
url: "/og-image-browse.png",
width: 1200,
height: 630,
alt: "Browse Dashboard Icons",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: "Browse Dashboard Icons Collection",
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
images: ["/og-image-browse.png"],
},
alternates: {
canonical: `${BASE_URL}/icons`,
},
}
export const dynamic = "force-static"
export default async function IconsPage() {
const icons = await getIconsArray()
return (
<div className="py-8">
<div className="space-y-4 mb-8 mx-auto max-w-[80vw]">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Browse icons</h1>
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
</div>
</div>
<IconSearch icons={icons} />
</div>
</div>
)
}

168
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { PostHogProvider } from "@/components/PostHogProvider"
import { Header } from "@/components/header"
import { LicenseNotice } from "@/components/license-notice"
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { Toaster } from "sonner"
import "./globals.css"
import { ThemeProvider } from "./theme-provider"
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#ffffff",
}
export const metadata: Metadata = {
metadataBase: new URL("https://dashboardicons.com"),
title: "Dashboard Icons",
description: "Curated icons for your dashboard",
keywords: ["dashboard", "icons", "open source", "free icons", "dashboard design"],
robots: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
googleBot: "index, follow",
},
openGraph: {
siteName: "Dashboard Icons",
type: "website",
locale: "en_US",
title: "Dashboard Icons",
description: "Curated icons for your dashboard",
url: "https://dashboardicons.com",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Dashboard Icons",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
site: "@homarr_app",
creator: "@homarr_app",
title: "Dashboard Icons",
description: "Curated icons for your dashboard",
images: ["/og-image.png"],
},
applicationName: "Dashboard Icons",
appleWebApp: {
title: "Dashboard Icons",
statusBarStyle: "default",
capable: true,
},
alternates: {
types: {
"application/rss+xml": "https://dashboardicons.com/rss.xml",
},
},
icons: {
icon: [
{
url: "/favicon.ico",
type: "image/x-icon",
},
{
url: "/favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
{
url: "/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
url: "/favicon-96x96.png",
sizes: "96x96",
type: "image/png",
},
{
url: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
],
shortcut: [
{
url: "/favicon.ico",
type: "image/x-icon",
},
],
apple: [
{
url: "/apple-icon-57x57.png",
sizes: "57x57",
type: "image/png",
},
{
url: "/apple-icon-60x60.png",
sizes: "60x60",
type: "image/png",
},
{
url: "/apple-icon-72x72.png",
sizes: "72x72",
type: "image/png",
},
{
url: "/apple-icon-76x76.png",
sizes: "76x76",
type: "image/png",
},
{
url: "/apple-icon-114x114.png",
sizes: "114x114",
type: "image/png",
},
{
url: "/apple-icon-120x120.png",
sizes: "120x120",
type: "image/png",
},
{
url: "/apple-icon-144x144.png",
sizes: "144x144",
type: "image/png",
},
{
url: "/apple-icon-152x152.png",
sizes: "152x152",
type: "image/png",
},
{
url: "/apple-icon-180x180.png",
sizes: "180x180",
type: "image/png",
},
],
},
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} antialiased bg-background`}>
<PostHogProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<Header />
<main>{children}</main>
<Toaster />
<LicenseNotice />
</ThemeProvider>
</PostHogProvider>
</body>
</html>
)
}

30
web/src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft } from "lucide-react"
import Link from "next/link"
export default function NotFound({
error,
}: {
error: Error & { digest?: string }
}) {
return (
<div className="py-16 flex items-center justify-center">
<div className="text-center space-y-6 max-w-md">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
<AlertTriangle className="w-8 h-8" />
</div>
<h1 className="text-2xl font-bold">Icon not found</h1>
<p className="text-muted-foreground">The icon you are looking for could not be found or there was an error loading it.</p>
<p className="text-muted-foreground">If you believe this is an error, please contact the maintainers of the repository.</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
<Button asChild>
<Link href="/icons">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to all icons
</Link>
</Button>
</div>
</div>
</div>
)
}

43
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { HeroSection } from "@/components/hero"
import { BASE_URL } from "@/constants"
import { getTotalIcons } from "@/lib/api"
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Dashboard Icons - Beautiful icons for your dashboard",
description: "Free, open-source icons for your dashboard. Choose from hundreds of high-quality icons for your web applications.",
keywords: ["self hosted", "dashboard icons", "free icons", "open source icons", "web dashboard", "application icons"],
openGraph: {
title: "Dashboard Icons - Your definitive source for dashboard icons",
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
type: "website",
url: BASE_URL,
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Dashboard Icons",
},
],
},
twitter: {
title: "Dashboard Icons - Your definitive source for dashboard icons",
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
card: "summary_large_image",
images: ["/og-image.png"],
},
alternates: {
canonical: BASE_URL,
},
}
export default async function Home() {
const { totalIcons } = await getTotalIcons()
return (
<div className="flex flex-col min-h-screen">
<HeroSection totalIcons={totalIcons} />
</div>
)
}

36
web/src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,36 @@
import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons } from "@/lib/api"
import type { MetadataRoute } from "next"
export const dynamic = "force-static"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const iconsData = await getAllIcons()
return [
{
url: WEB_URL,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: `${WEB_URL}/icons`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
images: [`${WEB_URL}/icons/icon.png`],
},
...Object.keys(iconsData).map((iconName) => ({
url: `${WEB_URL}/icons/${iconName}`,
lastModified: iconsData[iconName].update.timestamp,
changeFrequency: "yearly" as const,
priority: 0.8,
images: [
`${BASE_URL}/png/${iconName}.png`,
// SVG is conditional if it exists
iconsData[iconName].base === "svg" ? `${BASE_URL}/svg/${iconName}.svg` : null,
`${BASE_URL}/webp/${iconName}.webp`,
].filter(Boolean) as string[],
})),
]
}

View File

@@ -0,0 +1,8 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type * as React from "react"
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}