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

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>
)
}