feat(website): visually enhance website

- Update UI with refined rose-themed styling throughout the site
- Add mobile-responsive improvements to header and hero sections
- Create new 'Recently Added Icons' component with animated cards
- Improve icon details view with better visual hierarchy and theme indicators
- Implement better hover effects and transitions for interactive elements
- Add mobile menu for better navigation on smaller screens
- Update license notice wording
- Remove grid background in favor of refined blur effects
This commit is contained in:
Bjorn Lammers 2025-04-17 02:43:14 +02:00 committed by Thomas Camlong
parent 15f841cb09
commit 86b89f5518
No known key found for this signature in database
GPG Key ID: A678F374F428457B
21 changed files with 997 additions and 230 deletions

View File

@ -1 +1,26 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#FA5252","background_color":"#1B1B1D","display":"standalone"}
{
"name": "Dashboard Icons",
"short_name": "DashIcons",
"description": "A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"theme_color": "#FA5252",
"background_color": "#1B1B1D",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"categories": ["tools", "utilities", "productivity"]
}

View File

@ -1,6 +1,6 @@
import { IconDetails } from "@/components/icon-details"
import { BASE_URL } from "@/constants"
import { getAllIcons, getAuthorData } from "@/lib/api"
import { getAllIcons, getAuthorData, getTotalIcons } from "@/lib/api"
import type { Metadata, ResolvingMetadata } from "next"
import { notFound } from "next/navigation"
@ -36,7 +36,10 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
const iconImageUrl = `${BASE_URL}/png/${icon}.png`
const pageUrl = `${BASE_URL}/icons/${icon}`
const formattedIconName = icon.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
return {
title: `${formattedIconName} Icon | Dashboard Icons`,
@ -101,9 +104,65 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
notFound()
}
// Pass originalIconData directly, assuming IconDetails can handle it
const iconData = originalIconData
// Fetch total icons
const { totalIcons } = await getTotalIcons()
// Format icon name
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
const authorData = await getAuthorData(originalIconData.update.author.id)
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
return (
<div className="relative isolate overflow-hidden pt-14">
{/* Background glow effect */}
<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div
className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-rose-400/50 to-red-300/50 dark:from-red-600/70 dark:to-red-900/70 opacity-50 dark:opacity-60 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
{/* Secondary glow for additional effect */}
<div
className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]"
aria-hidden="true"
>
<div
className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-red-300/50 to-rose-500/50 dark:from-red-700/50 dark:to-red-500/50 opacity-50 dark:opacity-50 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
{/* Additional central glow */}
<div className="absolute inset-x-0 top-1/2 -z-10 transform-gpu overflow-hidden blur-3xl" aria-hidden="true">
<div
className="relative left-[calc(50%)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-rose-300/50 to-red-400/50 dark:from-red-800/40 dark:to-red-600/40 opacity-50 dark:opacity-40 sm:w-[50.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
{/* Title and Description Section */}
<div className="mx-auto max-w-7xl px-6 py-8 lg:px-8 text-center">
<h1 className="text-2xl md:text-4xl font-bold tracking-tight text-foreground sm:text-6xl">{formattedIconName} Icon</h1>
<p className="mt-3 md:mt-6 text-sm md:text-lg leading-6 md:leading-8 text-muted-foreground">
Part of a collection of {totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and
app directories.
</p>
</div>
{/* Existing Icon Details */}
<IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
</div>
)
}

View File

@ -4,6 +4,7 @@ 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 { motion } from "framer-motion"
import { Search } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
@ -82,43 +83,67 @@ export function IconSearch({ icons }: IconSearchProps) {
return (
<>
<div className="relative w-full sm:max-w-md">
<motion.div
className="relative w-full sm:max-w-md"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<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"
className="w-full pl-8 cursor-text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
</motion.div>
{filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto">
<motion.div
className="flex flex-col gap-8 py-12 max-w-2xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="text-center">
<h2 className="text-5xl font-semibold">We don't have this one...yet!</h2>
</div>
<IconSubmissionContent />
</div>
</motion.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}
{filteredIcons.map(({ name, data }, index) => (
<motion.div
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"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
delay: index * 0.03,
ease: "easeOut",
}}
>
<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>
<Link
prefetch={false}
href={`/icons/${name}`}
className="group flex flex-col items-center p-3 sm:p-4 rounded-lg border border-border bg-background/95 dark:bg-background/80 hover:border-rose-500 hover:bg-rose-500/10 dark:hover:bg-rose-900/30 dark:hover:border-rose-500 transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<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 duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group-hover:text-rose-600 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
</span>
</Link>
</motion.div>
))}
</div>
)}

View File

@ -52,16 +52,43 @@ 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>
<div className="relative isolate overflow-hidden">
{/* Main background glow */}
<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div
className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-rose-400/50 to-red-300/50 dark:from-red-600/60 dark:to-red-900/60 opacity-50 dark:opacity-50 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
<IconSearch icons={icons} />
{/* Secondary glow */}
<div
className="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]"
aria-hidden="true"
>
<div
className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-red-300/50 to-rose-500/50 dark:from-red-700/40 dark:to-red-500/40 opacity-50 dark:opacity-40 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
<div className="py-8">
<div className="space-y-4 mb-8 mx-auto max-w-[80vw] relative">
<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>
</div>
)

View File

@ -1,23 +1,28 @@
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";
import { PostHogProvider } from "@/components/PostHogProvider"
import { Footer } from "@/components/footer"
import { Header } from "@/components/header-wrapper"
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 { getTotalIcons } from "@/lib/api"
import { ThemeProvider } from "./theme-provider"
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
minimumScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: "#ffffff",
};
viewportFit: "cover",
}
export async function generateMetadata(): Promise<Metadata> {
const { totalIcons } = await getTotalIcons()
@ -26,14 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
metadataBase: new URL("https://dashboardicons.com"),
title: "Dashboard Icons - Your definitive source for dashboard icons",
description: `A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
keywords: [
"dashboard icons",
"service icons",
"application icons",
"tool icons",
"web dashboard",
"app directory",
],
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
robots: {
index: true,
follow: true,
@ -84,9 +82,7 @@ export async function generateMetadata(): Promise<Metadata> {
{ url: "/favicon-16x16.png", sizes: "16x16", 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",
@ -99,26 +95,20 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} antialiased bg-background`}>
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
<PostHogProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<Header />
<main>{children}</main>
<main className="flex-grow">{children}</main>
<Footer />
<Toaster />
<LicenseNotice />
</ThemeProvider>
</PostHogProvider>
</body>
</html>
);
)
}

View File

@ -1,6 +1,7 @@
import { HeroSection } from "@/components/hero"
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
import { BASE_URL } from "@/constants"
import { getTotalIcons } from "@/lib/api"
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> {
@ -9,14 +10,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: "Dashboard Icons - Beautiful icons for your dashboard",
description: `A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
keywords: [
"dashboard icons",
"service icons",
"application icons",
"tool icons",
"web dashboard",
"app directory",
],
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
openGraph: {
title: "Dashboard Icons - Your definitive source for dashboard icons",
description: `A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
@ -45,10 +39,12 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function Home() {
const { totalIcons } = await getTotalIcons()
const recentIcons = await getRecentlyAddedIcons(8)
return (
<div className="flex flex-col min-h-screen">
<HeroSection totalIcons={totalIcons} />
<RecentlyAddedIcons icons={recentIcons} />
</div>
)
}

View File

@ -1,17 +1,17 @@
import { BASE_URL, WEB_URL } from "@/constants";
import { getAllIcons } from "@/lib/api";
import type { MetadataRoute } from "next";
import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons } from "@/lib/api"
import type { MetadataRoute } from "next"
export const dynamic = "force-static";
export const dynamic = "force-static"
// Helper function to format dates as YYYY-MM-DD
const formatDate = (date: Date): string => {
// Format to YYYY-MM-DD
return date.toISOString().split('T')[0];
};
return date.toISOString().split("T")[0]
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const iconsData = await getAllIcons();
const iconsData = await getAllIcons()
return [
{
url: WEB_URL,
@ -34,11 +34,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
images: [
`${BASE_URL}/png/${iconName}.png`,
// SVG is conditional if it exists
iconsData[iconName].base === "svg"
? `${BASE_URL}/svg/${iconName}.svg`
: null,
iconsData[iconName].base === "svg" ? `${BASE_URL}/svg/${iconName}.svg` : null,
`${BASE_URL}/webp/${iconName}.webp`,
].filter(Boolean) as string[],
})),
];
]
}

View File

@ -0,0 +1,161 @@
"use client"
import { IconSubmissionForm } from "@/components/icon-submission-form"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
import { getAllIcons } from "@/lib/api"
import type { Icon } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Menu, Search } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { CommandMenu } from "./command-menu"
import { HeaderNav } from "./header-nav"
import { Button } from "./ui/button"
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
export function ClientHeader() {
const [icons, setIcons] = useState<Record<string, Icon>>({})
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
async function loadIcons() {
try {
const iconsData = await getAllIcons()
setIcons(iconsData)
setIsLoaded(true)
} catch (error) {
console.error("Failed to load icons:", error)
setIsLoaded(true)
}
}
loadIcons()
}, [])
return (
<motion.header
className="border-b sticky top-0 z-50 bg-background/95 backdrop-blur-md border-border/50"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6">
<Link href="/" className="text-lg md:text-xl font-bold group">
<span className="transition-colors duration-300 group-hover:text-rose-500">Dashboard Icons</span>
</Link>
<div className="hidden md:block">
<HeaderNav />
</div>
</div>
<div className="flex items-center gap-2 md:gap-4">
{/* Desktop search button */}
<div className="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 hover:border-rose-500 dark:hover:border-rose-500 transition-colors duration-200 shadow-sm"
id="desktop-search-button"
>
<Search className="h-4 w-4" />
<span>Search</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
<span className="text-xs"></span>K
</kbd>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search icons</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Mobile search button */}
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
id="mobile-search-button"
>
<Search className="h-5 w-5" />
<span className="sr-only">Search icons</span>
</Button>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{isLoaded && <CommandMenu icons={Object.keys(icons)} triggerButtonId="desktop-search-button" />}
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
asChild
>
<Link href={REPO_PATH} target="_blank" className="group">
<Github className="h-5 w-5 group-hover:text-rose-500 transition-colors duration-200" />
<span className="sr-only">GitHub</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>GitHub</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ThemeSwitcher />
{/* Mobile menu */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 rounded-full cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[280px] sm:w-[320px] p-0">
<div className="flex flex-col h-full py-6">
<div className="px-6 mb-6">
<h2 className="text-xl font-bold text-rose-500">Dashboard Icons</h2>
</div>
<div className="flex-1 overflow-auto px-6">
<nav className="space-y-6">
<HeaderNav />
<div className="border-t pt-6" />
{isLoaded && <CommandMenu icons={Object.keys(icons)} triggerButtonId="mobile-search-button" />}
<IconSubmissionForm />
<Link
href={REPO_PATH}
target="_blank"
className="flex items-center gap-2 text-sm font-medium text-rose-500 hover:text-rose-600 transition-colors cursor-pointer p-2 hover:bg-rose-500/5 rounded-md"
>
<Github className="h-4 w-4" />
GitHub Repository
</Link>
</nav>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</motion.header>
)
}

View File

@ -3,14 +3,18 @@
import { useRouter } from "next/navigation"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { ImageIcon, Search } from "lucide-react"
import Link from "next/link"
interface CommandMenuProps {
icons: string[]
triggerButtonId?: string
displayAsButton?: boolean
}
export function CommandMenu({ icons }: CommandMenuProps) {
export function CommandMenu({ icons, triggerButtonId, displayAsButton = false }: CommandMenuProps) {
const router = useRouter()
const [open, setOpen] = React.useState(false)
const [mounted, setMounted] = React.useState(false)
@ -37,6 +41,7 @@ export function CommandMenu({ icons }: CommandMenuProps) {
React.useEffect(() => {
setMounted(true)
}, [])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@ -49,6 +54,21 @@ export function CommandMenu({ icons }: CommandMenuProps) {
return () => document.removeEventListener("keydown", down)
}, [])
// Effect to connect to external trigger button
React.useEffect(() => {
if (!triggerButtonId || !mounted) return
const triggerButton = document.getElementById(triggerButtonId)
if (!triggerButton) return
const handleClick = () => {
setOpen(true)
}
triggerButton.addEventListener("click", handleClick)
return () => triggerButton.removeEventListener("click", handleClick)
}, [triggerButtonId, mounted])
const handleInputChange = React.useCallback((value: string) => {
setInputValue(value)
}, [])
@ -60,31 +80,25 @@ export function CommandMenu({ icons }: CommandMenuProps) {
},
[router],
)
if (!mounted) return null
return (
<>
<p className="text-sm text-muted-foreground">
Press{" "}
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
</kbd>{" "}
to search
</p>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} />
<CommandList className="max-h-[300px]">
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
{filteredIcons.map((icon) => (
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)}>
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary-foreground" />
<span className="capitalize">{icon.replace(/-/g, " ")}</span>
</Link>
</CommandItem>
))}
</CommandList>
</CommandDialog>
</>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} />
<CommandList className="max-h-[300px]">
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
{filteredIcons.map((icon) => (
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)} className="cursor-pointer">
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2 w-full">
<span className="text-rose-500">
<ImageIcon className="h-4 w-4" />
</span>
<span className="capitalize">{icon.replace(/-/g, " ")}</span>
</Link>
</CommandItem>
))}
</CommandList>
</CommandDialog>
)
}

View File

@ -0,0 +1,97 @@
"use client"
import { REPO_PATH } from "@/constants"
import { motion } from "framer-motion"
import { ExternalLink, Github, Heart } from "lucide-react"
import Link from "next/link"
export function Footer() {
return (
<footer className="border-t py-12 bg-background relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-rose-500/[0.03] via-transparent to-rose-500/[0.03]" />
<div className="container mx-auto px-4 md:px-6 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.
</p>
</motion.div>
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<h3 className="font-bold text-lg text-foreground/90">Links</h3>
<div className="flex flex-col gap-2">
<Link
href="/"
className="text-sm text-muted-foreground hover:text-rose-500 transition-colors duration-200 flex items-center w-fit"
>
<span>Home</span>
</Link>
<Link
href="/icons"
className="text-sm text-muted-foreground hover:text-rose-500 transition-colors duration-200 flex items-center w-fit"
>
<span>Icons</span>
</Link>
<Link
href={REPO_PATH}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-rose-500 transition-colors duration-200 flex items-center gap-1.5 w-fit group"
>
<span>GitHub</span>
<Github className="h-3.5 w-3.5 group-hover:text-rose-500 transition-colors duration-200 flex-shrink-0 self-center" />
</Link>
</div>
</motion.div>
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h3 className="font-bold text-lg text-foreground/90">Community</h3>
<p className="text-sm text-muted-foreground flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with <Heart className="h-3.5 w-3.5 text-rose-500 flex-shrink-0 animate-pulse" /> by Homarr Labs and the open source
community.
</p>
<Link
href="https://github.com/homarr-labs"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-rose-500 hover:text-rose-600 transition-colors duration-200 flex items-center gap-1.5 w-fit mt-1 group"
>
<span>Contribute to this project</span>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
</motion.div>
</div>
<motion.div
className="mt-10 pt-6 border-t text-center text-sm text-muted-foreground/80"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p>© {new Date().getFullYear()} Homarr Labs. All rights reserved.</p>
</motion.div>
</div>
</footer>
)
}

View File

@ -1,23 +0,0 @@
import { cn } from "@/lib/utils"
interface GridBackgroundProps {
className?: string
}
export function GridBackground({ className }: GridBackgroundProps) {
return (
<div className={cn("absolute inset-0 overflow-hidden", className)}>
{/* Grid pattern */}
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-slate-900 [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-slate-900" />
</div>
)
}

View File

@ -9,17 +9,23 @@ export function HeaderNav() {
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
return (
<nav className="flex items-center gap-2 md:gap-6">
<nav className="flex md:flex-row flex-col md:items-center items-start gap-4 md:gap-6">
<Link
href="/"
className={cn("text-sm font-medium transition-colors hover:text-primary", pathname === "/" && "text-primary font-semibold")}
className={cn(
"text-sm font-medium transition-colors hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer",
pathname === "/" && "text-primary font-semibold",
)}
>
Home
</Link>
<Link
prefetch
href="/icons"
className={cn("text-sm font-medium transition-colors hover:text-primary", isIconsActive && "text-primary font-semibold")}
className={cn(
"text-sm font-medium transition-colors hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer",
isIconsActive && "text-primary font-semibold",
)}
>
Icons
</Link>

View File

@ -0,0 +1,5 @@
import { ClientHeader } from "./client-header"
export function Header() {
return <ClientHeader />
}

View File

@ -1,33 +1,162 @@
"use client"
import { IconSubmissionForm } from "@/components/icon-submission-form"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
import { getAllIcons } from "@/lib/api"
import { Github } from "lucide-react"
import type { Icon } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Menu, Search } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { CommandMenu } from "./command-menu"
import { HeaderNav } from "./header-nav"
import { Button } from "./ui/button"
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
const icons = await getAllIcons()
export function Header() {
const [icons, setIcons] = useState<Record<string, Icon>>({})
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
async function loadIcons() {
try {
const iconsData = await getAllIcons()
setIcons(iconsData)
setIsLoaded(true)
} catch (error) {
console.error("Failed to load icons:", error)
setIsLoaded(true)
}
}
loadIcons()
}, [])
export async function Header() {
return (
<header className="border-b">
<div className="px-4 md:px-12 flex items-center justify-between h-16">
<motion.header
className="border-b sticky top-0 z-50 bg-background/95 backdrop-blur-md border-border/50"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6">
<Link href="/" className="text-lg md:text-xl font-bold">
Dashboard Icons
<Link href="/" className="text-lg md:text-xl font-bold group relative">
<span className="relative z-10 inline-block transition-colors duration-300 group-hover:text-rose-500">Dashboard Icons</span>
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-rose-500 group-hover:w-full transition-all duration-300 ease-in-out rounded-full" />
</Link>
<HeaderNav />
<div className="hidden md:block">
<HeaderNav />
</div>
</div>
<div className="flex items-center gap-2 md:gap-4">
<CommandMenu icons={Object.keys(icons)} />
<IconSubmissionForm />
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary">
<Github className="h-5 w-5" />
</Link>
{/* Desktop search button */}
<div className="hidden md:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 hover:border-rose-500 dark:hover:border-rose-500 transition-colors duration-200 shadow-sm"
id="desktop-search-button"
>
<Search className="h-4 w-4" />
<span>Search</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
<span className="text-xs"></span>K
</kbd>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search icons</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Mobile search button */}
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
id="mobile-search-button"
>
<Search className="h-5 w-5" />
<span className="sr-only">Search icons</span>
</Button>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{isLoaded && <CommandMenu icons={Object.keys(icons)} triggerButtonId="desktop-search-button" />}
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
asChild
>
<Link href={REPO_PATH} target="_blank" className="group">
<Github className="h-5 w-5 group-hover:text-rose-500 transition-colors duration-200" />
<span className="sr-only">GitHub</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>GitHub</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ThemeSwitcher />
{/* Mobile menu */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 rounded-full cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200 focus:ring-2 focus:ring-rose-500/20"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[280px] sm:w-[320px] p-0">
<div className="flex flex-col h-full py-6">
<div className="px-6 mb-6">
<h2 className="text-xl font-bold text-rose-500">Dashboard Icons</h2>
</div>
<div className="flex-1 overflow-auto px-6">
<nav className="space-y-6">
<HeaderNav />
<div className="border-t pt-6" />
{isLoaded && <CommandMenu icons={Object.keys(icons)} triggerButtonId="mobile-search-button" />}
<IconSubmissionForm />
<Link
href={REPO_PATH}
target="_blank"
className="flex items-center gap-2 text-sm font-medium text-rose-500 hover:text-rose-600 transition-colors cursor-pointer p-2 hover:bg-rose-500/5 rounded-md"
>
<Github className="h-4 w-4" />
GitHub Repository
</Link>
</nav>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</header>
</motion.header>
)
}

View File

@ -4,10 +4,10 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { motion } from "framer-motion"
import { Circle, Github, Search } from "lucide-react"
import { motion, useAnimation } from "framer-motion"
import { Circle, Github, Heart, Search, Sparkles } from "lucide-react"
import Link from "next/link"
import { useState } from "react"
import { useEffect, useState } from "react"
interface IconCardProps {
name: string
@ -31,7 +31,9 @@ function ElegantShape({
width = 400,
height = 100,
rotate = 0,
gradient = "from-background/[0.1]",
gradient = "from-rose-500/[0.5]",
mobileWidth,
mobileHeight,
}: {
className?: string
delay?: number
@ -39,7 +41,21 @@ function ElegantShape({
height?: number
rotate?: number
gradient?: string
mobileWidth?: number
mobileHeight?: number
}) {
const controls = useAnimation()
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
return (
<motion.div
initial={{
@ -70,20 +86,20 @@ function ElegantShape({
ease: "easeInOut",
}}
style={{
width,
height,
width: isMobile && mobileWidth ? mobileWidth : width,
height: isMobile && mobileHeight ? mobileHeight : height,
}}
className="relative"
>
<div
className={cn(
"absolute inset-0 rounded-full",
"bg-gradient-to-r to-transparent",
"bg-gradient-to-r from-rose-500/[0.6] via-rose-500/[0.4] to-rose-500/[0.1]",
gradient,
"backdrop-blur-[2px] border-2 border-white/[0.15]",
"shadow-[0_8px_32px_0_rgba(255,255,255,0.1)]",
"backdrop-blur-[3px]",
"shadow-[0_0_40px_0_rgba(244,63,94,0.35),inset_0_0_0_1px_rgba(244,63,94,0.2)]",
"after:absolute after:inset-0 after:rounded-full",
"after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.2),transparent_70%)]",
"after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.4),transparent_70%)]",
)}
/>
</motion.div>
@ -108,16 +124,18 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
}
return (
<div className="relative pt-40 w-full flex items-center justify-center overflow-hidden bg-background">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/[0.05] via-transparent to-rose-500/[0.05] blur-3xl" />
<div className="relative pt-20 md:pt-40 pb-10 md:pb-20 w-full flex items-center justify-center overflow-hidden bg-background">
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/[0.1] via-transparent to-rose-500/[0.1] blur-3xl" />
<div className="absolute inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<ElegantShape
delay={0.3}
width={600}
height={140}
mobileWidth={300}
mobileHeight={80}
rotate={12}
gradient="from-indigo-500/[0.15]"
gradient="from-rose-500/[0.6]"
className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]"
/>
@ -125,8 +143,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.5}
width={500}
height={120}
mobileWidth={250}
mobileHeight={70}
rotate={-15}
gradient="from-rose-500/[0.15]"
gradient="from-rose-500/[0.55]"
className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]"
/>
@ -134,8 +154,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.4}
width={300}
height={80}
mobileWidth={150}
mobileHeight={50}
rotate={-8}
gradient="from-violet-500/[0.15]"
gradient="from-rose-500/[0.65]"
className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]"
/>
@ -143,8 +165,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.6}
width={200}
height={60}
mobileWidth={100}
mobileHeight={40}
rotate={20}
gradient="from-amber-500/[0.15]"
gradient="from-rose-500/[0.58]"
className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]"
/>
@ -152,8 +176,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.7}
width={150}
height={40}
mobileWidth={80}
mobileHeight={30}
rotate={-25}
gradient="from-cyan-500/[0.15]"
gradient="from-rose-500/[0.62]"
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
/>
</div>
@ -161,29 +187,94 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
<div className="relative z-10 container mx-auto px-4 md:px-6">
<div className="max-w-4xl mx-auto text-center flex flex-col gap-4">
<Link prefetch href="https://github.com/homarr-labs" target="_blank" rel="noopener noreferrer" className="mx-auto">
<motion.div variants={fadeUpVariants} custom={0} initial="hidden" animate="visible">
<Card className="p-2 flex flex-row items-center gap-2 hover:scale-105 transition-all duration-300">
<Circle className="h-2 w-2 fill-rose-500/80" />
<span className="text-sm text-foreground/60 tracking-wide">by homarr-labs</span>
</Card>
<motion.div variants={fadeUpVariants} custom={0} initial="hidden" animate="visible" whileHover="hover">
<motion.div
className="overflow-hidden rounded-md relative"
variants={{
hover: {
scale: 1.05,
boxShadow: "0 10px 20px rgba(244, 63, 94, 0.15)",
},
}}
transition={{
type: "spring",
stiffness: 400,
damping: 17,
}}
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-rose-500/10 via-fuchsia-500/10 to-rose-500/10 opacity-0 z-0"
variants={{
hover: {
opacity: 1,
backgroundPosition: ["0% 0%", "100% 100%"],
},
}}
transition={{
duration: 1.5,
ease: "easeInOut",
backgroundPosition: {
repeat: Number.POSITIVE_INFINITY,
duration: 3,
},
}}
/>
<Card className="p-2 flex flex-row items-center gap-2 border-rose-200 dark:border-rose-900/30 shadow-sm bg-background z-10 relative">
<motion.div
variants={{
hover: {
scale: [1, 1.2, 1],
rotate: [0, 5, -5, 0],
},
}}
transition={{
duration: 0.6,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
}}
>
<Heart className="h-4 w-4 text-rose-500" />
</motion.div>
<span className="text-sm text-foreground/70 tracking-wide">Made with love by Homarr Labs</span>
</Card>
</motion.div>
</motion.div>
</Link>
<motion.div custom={1} variants={fadeUpVariants} initial="hidden" animate="visible">
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold mb-6 md:mb-8 tracking-tight">
<span className="bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/80">
<h1 className="text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight">
<span className="text-foreground relative inline-block">
Your definitive source for
<motion.span
className="absolute -right-4 sm:-right-6 md:-right-8 -top-3 sm:-top-4 md:-top-6 text-rose-500"
animate={{ rotate: [0, 15, 0] }}
transition={{ duration: 5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
>
<Sparkles className="h-4 w-4 sm:h-5 sm:w-5 md:h-8 md:w-8" />
</motion.span>
</span>
<br />
<span className={cn("bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-foreground/90 to-rose-300")}>
<motion.span
className="bg-clip-text text-transparent bg-gradient-to-r from-rose-500 via-fuchsia-500 to-rose-500 bg-[length:200%] relative inline-block"
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
textShadow: ["0 0 10px rgba(244,63,94,0.3)", "0 0 20px rgba(244,63,94,0.5)", "0 0 10px rgba(244,63,94,0.3)"],
}}
transition={{
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
dashboard icons.
</span>
</motion.span>
</h1>
</motion.div>
<motion.div custom={2} variants={fadeUpVariants} initial="hidden" animate="visible">
<p className="text-base sm:text-lg md:text-xl text-muted-foreground mb-8 leading-relaxed font-light tracking-wide max-w-2xl mx-auto px-4">
A collection of {totalIcons} beautiful, clean and consistent icons for your dashboard, application, or website.
<p className="text-sm sm:text-base md:text-xl text-muted-foreground mb-6 md:mb-8 leading-relaxed font-light tracking-wide max-w-2xl mx-auto px-4">
A collection of <span className="font-medium text-rose-500">{totalIcons}</span> curated icons for services, applications and
tools, designed specifically for dashboards and app directories.
</p>
</motion.div>
@ -192,29 +283,44 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
variants={fadeUpVariants}
initial="hidden"
animate="visible"
className="flex flex-col items-center gap-6 mb-12"
className="flex flex-col items-center gap-4 md:gap-6 mb-8 md:mb-12"
>
<form action="/icons" method="GET" className="relative w-full max-w-md">
<form action="/icons" method="GET" className="relative w-full max-w-md group">
<Input
name="q"
type="search"
placeholder={`Search ${totalIcons} icons...`}
className="pl-10 h-12 rounded-lg"
className="pl-10 h-10 md:h-12 rounded-lg border-muted-foreground/20 focus:border-rose-500 focus:ring-rose-500/20 transition-all"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 md:h-5 w-4 md:w-5 text-muted-foreground group-focus-within:text-rose-500 transition-colors" />
<motion.span
className="absolute inset-0 -z-10 rounded-lg bg-rose-500/5 opacity-0 transition-opacity group-hover:opacity-100"
initial={{ scale: 0.95 }}
whileHover={{ scale: 1 }}
transition={{ duration: 0.2 }}
/>
</form>
<div className="flex gap-4">
<Button variant="default" className="rounded-lg" size="lg" asChild>
<Link href="/icons" className="flex items-center">
<div className="flex gap-3 md:gap-4 flex-wrap justify-center">
<Button variant="default" className="h-9 md:h-10 px-4 gap-2 bg-rose-500 hover:bg-rose-600 text-white" asChild>
<Link href="/icons" className="flex items-center text-sm md:text-base">
Browse all icons
</Link>
</Button>
<Button variant="outline" size="lg" className="gap-2" asChild>
<Link href="https://github.com/homarr-labs/dashboard-icons" target="_blank" rel="noopener noreferrer">
<Button
variant="outline"
className="h-9 md:h-10 px-4 gap-2 border-rose-200 dark:border-rose-900/30 hover:bg-rose-50 dark:hover:bg-rose-900/20 hover:border-rose-300 dark:hover:border-rose-800"
asChild
>
<Link
href="https://github.com/homarr-labs/dashboard-icons"
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm md:text-base"
>
GitHub
<Github className="h-4 w-4" />
<Github className="h-4 w-4 ml-1" />
</Link>
</Button>
</div>

View File

@ -7,7 +7,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { BASE_URL, REPO_PATH } from "@/constants"
import type { AuthorData, Icon } from "@/types/icons"
import { motion } from "framer-motion"
import { Check, Copy, Download, Github } from "lucide-react"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useState } from "react"
@ -69,19 +69,19 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
return (
<TooltipProvider key={variantKey}>
<div className="flex flex-col items-center bg-card rounded-lg p-4 border shadow-sm hover:shadow-md transition-all">
<div className="flex flex-col items-center bg-background/90 dark:bg-background/50 rounded-lg p-4 border border-rose-100/50 dark:border-rose-950/50 shadow-sm hover:shadow-md transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-md overflow-hidden group"
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleCopy(url, variantKey)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-md z-10 transition-colors" />
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-md"
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
@ -99,7 +99,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
src={url}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-2"
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
@ -113,7 +113,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8" asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<a href={url} download={`${iconName}.${format}`}>
<Download className="w-4 h-4" />
</a>
@ -129,7 +129,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<Button
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={() => handleCopy(url, `btn-${variantKey}`)}
>
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
@ -142,7 +142,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8" asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</Link>
@ -163,10 +163,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column: Icon Info and Author */}
<div className="lg:col-span-1">
<Card className="h-full">
<Card className="h-full backdrop-blur-sm bg-background/95 dark:bg-background/80 border shadow-lg">
<CardHeader className="pb-4">
<div className="flex flex-col items-center">
<div className="relative w-32 h-32 bg-background rounded-xl overflow-hidden border flex items-center justify-center p-3 mb-4">
<div className="relative w-32 h-32 bg-background/90 rounded-xl overflow-hidden border flex items-center justify-center p-3 mb-4">
<Image
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
width={96}
@ -194,14 +194,18 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar>
<Link
href={authorData.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm"
>
{authorName}
</Link>
{authorData.html_url ? (
<Link
href={authorData.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm"
>
{authorName}
</Link>
) : (
<span className="text-sm">{authorName}</span>
)}
</div>
</div>
</div>
@ -239,7 +243,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{/* Middle Column: Icon variants */}
<div className="lg:col-span-2">
<Card className="h-full">
<Card className="h-full backdrop-blur-sm bg-background/95 dark:bg-background/80 border shadow-lg">
<CardHeader>
<CardTitle>Icon variants</CardTitle>
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
@ -253,19 +257,19 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="space-y-10">
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
<Sun className="w-4 h-4 text-amber-500" />
Light theme
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg bg-background/50 dark:bg-background/30 border border-rose-100 dark:border-rose-950/40">
{availableFormats.map((format) => renderVariant(format, icon, "light"))}
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
<Moon className="w-4 h-4 text-indigo-500" />
Dark theme
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg bg-background/50 dark:bg-background/30 border border-rose-100 dark:border-rose-950/40">
{availableFormats.map((format) => renderVariant(format, icon, "dark"))}
</div>
</div>
@ -277,7 +281,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{/* Right Column: Technical details */}
<div className="lg:col-span-1">
<Card className="h-full">
<Card className="h-full backdrop-blur-sm bg-background/95 dark:bg-background/80 border shadow-lg">
<CardHeader>
<CardTitle>Technical details</CardTitle>
</CardHeader>
@ -286,8 +290,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-primary/80" />
<div className="px-3 py-1.5 bg-muted rounded-md text-sm font-medium">{iconData.base.toUpperCase()}</div>
<FileType className="w-4 h-4 text-blue-500" />
<div className="px-3 py-1.5 bg-background/80 dark:bg-background/50 border border-border rounded-lg text-sm font-medium">
{iconData.base.toUpperCase()}
</div>
</div>
</div>
@ -295,7 +301,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
<div className="flex flex-wrap gap-2">
{availableFormats.map((format) => (
<div key={format} className="px-3 py-1.5 bg-muted rounded-md text-xs font-medium">
<div
key={format}
className="px-3 py-1.5 bg-background/80 dark:bg-background/50 border border-border rounded-lg text-xs font-medium"
>
{format.toUpperCase()}
</div>
))}
@ -308,9 +317,11 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="space-y-2">
{Object.entries(iconData.colors).map(([theme, variant]) => (
<div key={theme} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-primary/80" />
<PaletteIcon className="w-4 h-4 text-purple-500" />
<span className="capitalize font-medium text-sm">{theme}:</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs">{variant}</code>
<code className="bg-background/80 dark:bg-background/50 border border-border px-2 py-0.5 rounded-lg text-xs">
{variant}
</code>
</div>
))}
</div>

View File

@ -49,7 +49,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
<Button
key={template.id}
variant="outline"
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer"
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 hover:border-rose-500 dark:hover:border-rose-500 transition-colors duration-200"
>
<div className="flex w-full items-center justify-between">
<span className="font-medium">{template.name}</span>
@ -69,7 +69,10 @@ export function IconSubmissionForm() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex">
<Button
variant="outline"
className="hidden md:inline-flex cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 hover:border-rose-500 dark:hover:border-rose-500 transition-colors duration-200"
>
<PlusCircle className="h-4 w-4" /> Suggest new icon
</Button>
</DialogTrigger>

View File

@ -38,8 +38,8 @@ export function LicenseNotice() {
<div className="flex items-start justify-between">
<div className="text-xs text-muted-foreground space-y-1">
<p>
Unless otherwise indicated, all images and assets are the property of their respective owners and used for identification
purposes only.
All product names, trademarks, and registered trademarks are the property of their respective owners. Icons are used for
identification purposes only and do not imply endorsement.
</p>
<p>
Read the{" "}

View File

@ -0,0 +1,114 @@
"use client"
import { BASE_URL } from "@/constants"
import type { IconWithName } from "@/types/icons"
import { format, isToday, isYesterday } from "date-fns"
import { motion } from "framer-motion"
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
function formatIconDate(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return "Today"
}
if (isYesterday(date)) {
return "Yesterday"
}
return format(date, "MMM d, yyyy")
}
export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
return (
<div className="relative isolate overflow-hidden py-16 md:py-24">
{/* Background glow */}
<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div
className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-rose-400/40 to-red-300/40 dark:from-red-600/50 dark:to-red-900/50 opacity-60 dark:opacity-50 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style={{
clipPath:
"polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)",
}}
/>
</div>
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<motion.div
className="mx-auto max-w-2xl text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl bg-clip-text text-transparent bg-gradient-to-r from-rose-600 to-rose-500">
Recently Added Icons
</h2>
<p className="mt-3 text-lg text-muted-foreground">Check out the latest additions to our collection.</p>
</motion.div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-8 gap-4 md:gap-5">
{icons.map(({ name, data }, index) => (
<motion.div
key={name}
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.5,
delay: index * 0.05,
ease: "easeOut",
}}
>
<Link
prefetch={false}
href={`/icons/${name}`}
className="group flex flex-col items-center p-3 sm:p-4 rounded-lg border border-border bg-background/95 dark:bg-background/80 hover:border-rose-500 hover:bg-rose-500/10 dark:hover:bg-rose-900/30 dark:hover:border-rose-500 transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<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 duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group-hover:text-rose-600 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
</span>
<div className="flex items-center justify-center mt-2 w-full">
<span className="text-[10px] sm:text-xs text-muted-foreground flex items-center whitespace-nowrap group-hover:text-rose-500/70 transition-colors duration-200">
<Clock className="w-3 h-3 mr-1 shrink-0" />
{formatIconDate(data.update.timestamp)}
</span>
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<ExternalLink className="w-3 h-3 text-rose-500" />
</div>
</Link>
</motion.div>
))}
</div>
<motion.div
className="mt-12 text-center"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.4 }}
>
<Link
href="/icons"
className="text-rose-500 dark:text-rose-400 hover:text-rose-600 dark:hover:text-rose-300 font-medium inline-flex items-center py-2 px-4 rounded-full border border-rose-200 dark:border-rose-800/30 hover:bg-rose-50 dark:hover:bg-rose-900/20 transition-all duration-200 group"
>
View all icons
<ArrowRight className="w-4 h-4 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
</Link>
</motion.div>
</div>
</div>
)
}

View File

@ -12,16 +12,26 @@ export function ThemeSwitcher() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="hover:text-primary" variant="ghost" size="icon">
<Button
className="rounded-lg cursor-pointer hover:bg-rose-500/10 dark:hover:bg-rose-900/30 transition-colors duration-200"
variant="ghost"
size="icon"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className=" absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -72,3 +72,17 @@ export async function getTotalIcons() {
totalIcons: Object.keys(iconsData).length,
}
}
/**
* Fetches recently added icons sorted by timestamp
*/
export async function getRecentlyAddedIcons(limit = 8): Promise<IconWithName[]> {
const icons = await getIconsArray()
return icons
.sort((a, b) => {
// Sort by timestamp in descending order (newest first)
return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
})
.slice(0, limit)
}