mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-06-28 15:30:22 +08:00
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:
parent
15f841cb09
commit
86b89f5518
@ -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"]
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { IconDetails } from "@/components/icon-details"
|
import { IconDetails } from "@/components/icon-details"
|
||||||
import { BASE_URL } from "@/constants"
|
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 type { Metadata, ResolvingMetadata } from "next"
|
||||||
import { notFound } from "next/navigation"
|
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 iconImageUrl = `${BASE_URL}/png/${icon}.png`
|
||||||
const pageUrl = `${BASE_URL}/icons/${icon}`
|
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 {
|
return {
|
||||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||||
@ -101,9 +104,65 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass originalIconData directly, assuming IconDetails can handle it
|
// Fetch total icons
|
||||||
const iconData = originalIconData
|
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)
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { IconSubmissionContent } from "@/components/icon-submission-form"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import type { IconSearchProps } from "@/types/icons"
|
import type { IconSearchProps } from "@/types/icons"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@ -82,43 +83,67 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
|
|
||||||
return (
|
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" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search icons by name, aliases, or categories..."
|
placeholder="Search icons by name, aliases, or categories..."
|
||||||
className="w-full pl-8"
|
className="w-full pl-8 cursor-text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{filteredIcons.length === 0 ? (
|
{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">
|
<div className="text-center">
|
||||||
<h2 className="text-5xl font-semibold">We don't have this one...yet!</h2>
|
<h2 className="text-5xl font-semibold">We don't have this one...yet!</h2>
|
||||||
</div>
|
</div>
|
||||||
<IconSubmissionContent />
|
<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">
|
<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 }) => (
|
{filteredIcons.map(({ name, data }, index) => (
|
||||||
<Link
|
<motion.div
|
||||||
prefetch={false}
|
|
||||||
key={name}
|
key={name}
|
||||||
href={`/icons/${name}`}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
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"
|
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">
|
<Link
|
||||||
<Image
|
prefetch={false}
|
||||||
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
|
href={`/icons/${name}`}
|
||||||
alt={`${name} icon`}
|
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"
|
||||||
fill
|
>
|
||||||
className="object-contain p-1 group-hover:scale-110 transition-transform"
|
<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>
|
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
|
||||||
<span className="text-xs sm:text-sm text-center truncate w-full capitalize">{name.replace(/-/g, " ")}</span>
|
<Image
|
||||||
</Link>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -52,16 +52,43 @@ export const dynamic = "force-static"
|
|||||||
export default async function IconsPage() {
|
export default async function IconsPage() {
|
||||||
const icons = await getIconsArray()
|
const icons = await getIconsArray()
|
||||||
return (
|
return (
|
||||||
<div className="py-8">
|
<div className="relative isolate overflow-hidden">
|
||||||
<div className="space-y-4 mb-8 mx-auto max-w-[80vw]">
|
{/* Main background glow */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
|
||||||
<div>
|
<div
|
||||||
<h1 className="text-3xl font-bold">Browse icons</h1>
|
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]"
|
||||||
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
|
style={{
|
||||||
</div>
|
clipPath:
|
||||||
</div>
|
"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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
import { PostHogProvider } from "@/components/PostHogProvider";
|
import { PostHogProvider } from "@/components/PostHogProvider"
|
||||||
import { Header } from "@/components/header";
|
import { Footer } from "@/components/footer"
|
||||||
import { LicenseNotice } from "@/components/license-notice";
|
import { Header } from "@/components/header-wrapper"
|
||||||
import type { Metadata, Viewport } from "next";
|
import { LicenseNotice } from "@/components/license-notice"
|
||||||
import { Inter } from "next/font/google";
|
import type { Metadata, Viewport } from "next"
|
||||||
import { Toaster } from "sonner";
|
import { Inter } from "next/font/google"
|
||||||
import "./globals.css";
|
import { Toaster } from "sonner"
|
||||||
import { ThemeProvider } from "./theme-provider";
|
import "./globals.css"
|
||||||
import { getTotalIcons } from "@/lib/api"
|
import { getTotalIcons } from "@/lib/api"
|
||||||
|
import { ThemeProvider } from "./theme-provider"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
})
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
|
minimumScale: 1,
|
||||||
|
maximumScale: 5,
|
||||||
|
userScalable: true,
|
||||||
themeColor: "#ffffff",
|
themeColor: "#ffffff",
|
||||||
};
|
viewportFit: "cover",
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const { totalIcons } = await getTotalIcons()
|
const { totalIcons } = await getTotalIcons()
|
||||||
@ -26,14 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
metadataBase: new URL("https://dashboardicons.com"),
|
metadataBase: new URL("https://dashboardicons.com"),
|
||||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
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.`,
|
description: `A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
keywords: [
|
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||||
"dashboard icons",
|
|
||||||
"service icons",
|
|
||||||
"application icons",
|
|
||||||
"tool icons",
|
|
||||||
"web dashboard",
|
|
||||||
"app directory",
|
|
||||||
],
|
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
@ -84,9 +82,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
],
|
],
|
||||||
apple: [
|
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||||
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
|
||||||
],
|
|
||||||
other: [
|
other: [
|
||||||
{
|
{
|
||||||
rel: "mask-icon",
|
rel: "mask-icon",
|
||||||
@ -99,26 +95,20 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
children,
|
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<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>
|
<PostHogProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<Header />
|
<Header />
|
||||||
<main>{children}</main>
|
<main className="flex-grow">{children}</main>
|
||||||
|
<Footer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<LicenseNotice />
|
<LicenseNotice />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { HeroSection } from "@/components/hero"
|
import { HeroSection } from "@/components/hero"
|
||||||
|
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import { getTotalIcons } from "@/lib/api"
|
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
@ -9,14 +10,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: "Dashboard Icons - Beautiful icons for your dashboard",
|
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.`,
|
description: `A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
keywords: [
|
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||||
"dashboard icons",
|
|
||||||
"service icons",
|
|
||||||
"application icons",
|
|
||||||
"tool icons",
|
|
||||||
"web dashboard",
|
|
||||||
"app directory",
|
|
||||||
],
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
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.`,
|
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() {
|
export default async function Home() {
|
||||||
const { totalIcons } = await getTotalIcons()
|
const { totalIcons } = await getTotalIcons()
|
||||||
|
const recentIcons = await getRecentlyAddedIcons(8)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<HeroSection totalIcons={totalIcons} />
|
<HeroSection totalIcons={totalIcons} />
|
||||||
|
<RecentlyAddedIcons icons={recentIcons} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import { BASE_URL, WEB_URL } from "@/constants";
|
import { BASE_URL, WEB_URL } from "@/constants"
|
||||||
import { getAllIcons } from "@/lib/api";
|
import { getAllIcons } from "@/lib/api"
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next"
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
export const dynamic = "force-static"
|
||||||
|
|
||||||
// Helper function to format dates as YYYY-MM-DD
|
// Helper function to format dates as YYYY-MM-DD
|
||||||
const formatDate = (date: Date): string => {
|
const formatDate = (date: Date): string => {
|
||||||
// Format to YYYY-MM-DD
|
// 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> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const iconsData = await getAllIcons();
|
const iconsData = await getAllIcons()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: WEB_URL,
|
url: WEB_URL,
|
||||||
@ -34,11 +34,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
images: [
|
images: [
|
||||||
`${BASE_URL}/png/${iconName}.png`,
|
`${BASE_URL}/png/${iconName}.png`,
|
||||||
// SVG is conditional if it exists
|
// SVG is conditional if it exists
|
||||||
iconsData[iconName].base === "svg"
|
iconsData[iconName].base === "svg" ? `${BASE_URL}/svg/${iconName}.svg` : null,
|
||||||
? `${BASE_URL}/svg/${iconName}.svg`
|
|
||||||
: null,
|
|
||||||
`${BASE_URL}/webp/${iconName}.webp`,
|
`${BASE_URL}/webp/${iconName}.webp`,
|
||||||
].filter(Boolean) as string[],
|
].filter(Boolean) as string[],
|
||||||
})),
|
})),
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
161
web/src/components/client-header.tsx
Normal file
161
web/src/components/client-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -3,14 +3,18 @@
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||||
|
import { ImageIcon, Search } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
interface CommandMenuProps {
|
interface CommandMenuProps {
|
||||||
icons: string[]
|
icons: string[]
|
||||||
|
triggerButtonId?: string
|
||||||
|
displayAsButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandMenu({ icons }: CommandMenuProps) {
|
export function CommandMenu({ icons, triggerButtonId, displayAsButton = false }: CommandMenuProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
@ -37,6 +41,7 @@ export function CommandMenu({ icons }: CommandMenuProps) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
@ -49,6 +54,21 @@ export function CommandMenu({ icons }: CommandMenuProps) {
|
|||||||
return () => document.removeEventListener("keydown", down)
|
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) => {
|
const handleInputChange = React.useCallback((value: string) => {
|
||||||
setInputValue(value)
|
setInputValue(value)
|
||||||
}, [])
|
}, [])
|
||||||
@ -60,31 +80,25 @@ export function CommandMenu({ icons }: CommandMenuProps) {
|
|||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} />
|
||||||
Press{" "}
|
<CommandList className="max-h-[300px]">
|
||||||
<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">
|
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
|
||||||
<span className="text-xs">⌘</span>K
|
{filteredIcons.map((icon) => (
|
||||||
</kbd>{" "}
|
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)} className="cursor-pointer">
|
||||||
to search
|
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2 w-full">
|
||||||
</p>
|
<span className="text-rose-500">
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<ImageIcon className="h-4 w-4" />
|
||||||
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} />
|
</span>
|
||||||
<CommandList className="max-h-[300px]">
|
<span className="capitalize">{icon.replace(/-/g, " ")}</span>
|
||||||
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
|
</Link>
|
||||||
{filteredIcons.map((icon) => (
|
</CommandItem>
|
||||||
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)}>
|
))}
|
||||||
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2">
|
</CommandList>
|
||||||
<div className="w-2 h-2 bg-primary-foreground" />
|
</CommandDialog>
|
||||||
<span className="capitalize">{icon.replace(/-/g, " ")}</span>
|
|
||||||
</Link>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
97
web/src/components/footer.tsx
Normal file
97
web/src/components/footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -9,17 +9,23 @@ export function HeaderNav() {
|
|||||||
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
|
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
|
||||||
|
|
||||||
return (
|
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
|
<Link
|
||||||
href="/"
|
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
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
prefetch
|
prefetch
|
||||||
href="/icons"
|
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
|
Icons
|
||||||
</Link>
|
</Link>
|
||||||
|
5
web/src/components/header-wrapper.tsx
Normal file
5
web/src/components/header-wrapper.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { ClientHeader } from "./client-header"
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return <ClientHeader />
|
||||||
|
}
|
@ -1,33 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { IconSubmissionForm } from "@/components/icon-submission-form"
|
import { IconSubmissionForm } from "@/components/icon-submission-form"
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher"
|
import { ThemeSwitcher } from "@/components/theme-switcher"
|
||||||
import { REPO_PATH } from "@/constants"
|
import { REPO_PATH } from "@/constants"
|
||||||
import { getAllIcons } from "@/lib/api"
|
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 Link from "next/link"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { CommandMenu } from "./command-menu"
|
import { CommandMenu } from "./command-menu"
|
||||||
import { HeaderNav } from "./header-nav"
|
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 (
|
return (
|
||||||
<header className="border-b">
|
<motion.header
|
||||||
<div className="px-4 md:px-12 flex items-center justify-between h-16">
|
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">
|
<div className="flex items-center gap-2 md:gap-6">
|
||||||
<Link href="/" className="text-lg md:text-xl font-bold">
|
<Link href="/" className="text-lg md:text-xl font-bold group relative">
|
||||||
Dashboard Icons
|
<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>
|
</Link>
|
||||||
<HeaderNav />
|
<div className="hidden md:block">
|
||||||
|
<HeaderNav />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
<CommandMenu icons={Object.keys(icons)} />
|
{/* Desktop search button */}
|
||||||
<IconSubmissionForm />
|
<div className="hidden md:block">
|
||||||
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary">
|
<TooltipProvider>
|
||||||
<Github className="h-5 w-5" />
|
<Tooltip>
|
||||||
</Link>
|
<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 />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</motion.header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { motion } from "framer-motion"
|
import { motion, useAnimation } from "framer-motion"
|
||||||
import { Circle, Github, Search } from "lucide-react"
|
import { Circle, Github, Heart, Search, Sparkles } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
interface IconCardProps {
|
interface IconCardProps {
|
||||||
name: string
|
name: string
|
||||||
@ -31,7 +31,9 @@ function ElegantShape({
|
|||||||
width = 400,
|
width = 400,
|
||||||
height = 100,
|
height = 100,
|
||||||
rotate = 0,
|
rotate = 0,
|
||||||
gradient = "from-background/[0.1]",
|
gradient = "from-rose-500/[0.5]",
|
||||||
|
mobileWidth,
|
||||||
|
mobileHeight,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
delay?: number
|
delay?: number
|
||||||
@ -39,7 +41,21 @@ function ElegantShape({
|
|||||||
height?: number
|
height?: number
|
||||||
rotate?: number
|
rotate?: number
|
||||||
gradient?: string
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{
|
initial={{
|
||||||
@ -70,20 +86,20 @@ function ElegantShape({
|
|||||||
ease: "easeInOut",
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width,
|
width: isMobile && mobileWidth ? mobileWidth : width,
|
||||||
height,
|
height: isMobile && mobileHeight ? mobileHeight : height,
|
||||||
}}
|
}}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 rounded-full",
|
"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,
|
gradient,
|
||||||
"backdrop-blur-[2px] border-2 border-white/[0.15]",
|
"backdrop-blur-[3px]",
|
||||||
"shadow-[0_8px_32px_0_rgba(255,255,255,0.1)]",
|
"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: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>
|
</motion.div>
|
||||||
@ -108,16 +124,18 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative pt-40 w-full flex items-center justify-center overflow-hidden bg-background">
|
<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-indigo-500/[0.05] via-transparent to-rose-500/[0.05] blur-3xl" />
|
<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
|
<ElegantShape
|
||||||
delay={0.3}
|
delay={0.3}
|
||||||
width={600}
|
width={600}
|
||||||
height={140}
|
height={140}
|
||||||
|
mobileWidth={300}
|
||||||
|
mobileHeight={80}
|
||||||
rotate={12}
|
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%]"
|
className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -125,8 +143,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
delay={0.5}
|
delay={0.5}
|
||||||
width={500}
|
width={500}
|
||||||
height={120}
|
height={120}
|
||||||
|
mobileWidth={250}
|
||||||
|
mobileHeight={70}
|
||||||
rotate={-15}
|
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%]"
|
className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -134,8 +154,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
delay={0.4}
|
delay={0.4}
|
||||||
width={300}
|
width={300}
|
||||||
height={80}
|
height={80}
|
||||||
|
mobileWidth={150}
|
||||||
|
mobileHeight={50}
|
||||||
rotate={-8}
|
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%]"
|
className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -143,8 +165,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
delay={0.6}
|
delay={0.6}
|
||||||
width={200}
|
width={200}
|
||||||
height={60}
|
height={60}
|
||||||
|
mobileWidth={100}
|
||||||
|
mobileHeight={40}
|
||||||
rotate={20}
|
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%]"
|
className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -152,8 +176,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
delay={0.7}
|
delay={0.7}
|
||||||
width={150}
|
width={150}
|
||||||
height={40}
|
height={40}
|
||||||
|
mobileWidth={80}
|
||||||
|
mobileHeight={30}
|
||||||
rotate={-25}
|
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%]"
|
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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">
|
<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">
|
<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">
|
<motion.div variants={fadeUpVariants} custom={0} initial="hidden" animate="visible" whileHover="hover">
|
||||||
<Card className="p-2 flex flex-row items-center gap-2 hover:scale-105 transition-all duration-300">
|
<motion.div
|
||||||
<Circle className="h-2 w-2 fill-rose-500/80" />
|
className="overflow-hidden rounded-md relative"
|
||||||
<span className="text-sm text-foreground/60 tracking-wide">by homarr-labs</span>
|
variants={{
|
||||||
</Card>
|
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>
|
</motion.div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<motion.div custom={1} variants={fadeUpVariants} initial="hidden" animate="visible">
|
<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">
|
<h1 className="text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight">
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/80">
|
<span className="text-foreground relative inline-block">
|
||||||
Your definitive source for
|
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>
|
</span>
|
||||||
<br />
|
<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.
|
dashboard icons.
|
||||||
</span>
|
</motion.span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div custom={2} variants={fadeUpVariants} initial="hidden" animate="visible">
|
<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">
|
<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 {totalIcons} beautiful, clean and consistent icons for your dashboard, application, or website.
|
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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -192,29 +283,44 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
|||||||
variants={fadeUpVariants}
|
variants={fadeUpVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
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
|
<Input
|
||||||
name="q"
|
name="q"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={`Search ${totalIcons} icons...`}
|
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}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
</form>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-3 md:gap-4 flex-wrap justify-center">
|
||||||
<Button variant="default" className="rounded-lg" size="lg" asChild>
|
<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">
|
<Link href="/icons" className="flex items-center text-sm md:text-base">
|
||||||
Browse all icons
|
Browse all icons
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="lg" className="gap-2" asChild>
|
<Button
|
||||||
<Link href="https://github.com/homarr-labs/dashboard-icons" target="_blank" rel="noopener noreferrer">
|
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
|
||||||
<Github className="h-4 w-4" />
|
<Github className="h-4 w-4 ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,7 +7,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||||||
import { BASE_URL, REPO_PATH } from "@/constants"
|
import { BASE_URL, REPO_PATH } from "@/constants"
|
||||||
import type { AuthorData, Icon } from "@/types/icons"
|
import type { AuthorData, Icon } from "@/types/icons"
|
||||||
import { motion } from "framer-motion"
|
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 Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
@ -69,19 +69,19 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider key={variantKey}>
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<motion.div
|
<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 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => handleCopy(url, variantKey)}
|
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
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: isCopied ? 1 : 0 }}
|
animate={{ opacity: isCopied ? 1 : 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
@ -99,7 +99,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
src={url}
|
src={url}
|
||||||
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-2"
|
className="object-contain p-4"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -113,7 +113,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<div className="flex gap-2 mt-3 w-full justify-center">
|
<div className="flex gap-2 mt-3 w-full justify-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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}`}>
|
<a href={url} download={`${iconName}.${format}`}>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
@ -129,7 +129,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 cursor-pointer"
|
className="h-8 w-8 rounded-lg cursor-pointer"
|
||||||
onClick={() => handleCopy(url, `btn-${variantKey}`)}
|
onClick={() => handleCopy(url, `btn-${variantKey}`)}
|
||||||
>
|
>
|
||||||
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
{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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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">
|
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -163,10 +163,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Left Column: Icon Info and Author */}
|
{/* Left Column: Icon Info and Author */}
|
||||||
<div className="lg:col-span-1">
|
<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">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex flex-col items-center">
|
<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
|
<Image
|
||||||
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
||||||
width={96}
|
width={96}
|
||||||
@ -194,14 +194,18 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<AvatarImage src={authorData.avatar_url} alt={authorName} />
|
<AvatarImage src={authorData.avatar_url} alt={authorName} />
|
||||||
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
|
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Link
|
{authorData.html_url ? (
|
||||||
href={authorData.html_url}
|
<Link
|
||||||
target="_blank"
|
href={authorData.html_url}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="text-primary hover:underline text-sm"
|
rel="noopener noreferrer"
|
||||||
>
|
className="text-primary hover:underline text-sm"
|
||||||
{authorName}
|
>
|
||||||
</Link>
|
{authorName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{authorName}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -239,7 +243,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
|
|
||||||
{/* Middle Column: Icon variants */}
|
{/* Middle Column: Icon variants */}
|
||||||
<div className="lg:col-span-2">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>Icon variants</CardTitle>
|
<CardTitle>Icon variants</CardTitle>
|
||||||
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
|
<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 className="space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<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
|
Light theme
|
||||||
</h3>
|
</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"))}
|
{availableFormats.map((format) => renderVariant(format, icon, "light"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<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
|
Dark theme
|
||||||
</h3>
|
</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"))}
|
{availableFormats.map((format) => renderVariant(format, icon, "dark"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -277,7 +281,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
|
|
||||||
{/* Right Column: Technical details */}
|
{/* Right Column: Technical details */}
|
||||||
<div className="lg:col-span-1">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>Technical details</CardTitle>
|
<CardTitle>Technical details</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -286,8 +290,10 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-full bg-primary/80" />
|
<FileType className="w-4 h-4 text-blue-500" />
|
||||||
<div className="px-3 py-1.5 bg-muted rounded-md text-sm font-medium">{iconData.base.toUpperCase()}</div>
|
<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>
|
||||||
</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>
|
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{availableFormats.map((format) => (
|
{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()}
|
{format.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -308,9 +317,11 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(iconData.colors).map(([theme, variant]) => (
|
{Object.entries(iconData.colors).map(([theme, variant]) => (
|
||||||
<div key={theme} className="flex items-center gap-2">
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -49,7 +49,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
|
|||||||
<Button
|
<Button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
variant="outline"
|
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">
|
<div className="flex w-full items-center justify-between">
|
||||||
<span className="font-medium">{template.name}</span>
|
<span className="font-medium">{template.name}</span>
|
||||||
@ -69,7 +69,10 @@ export function IconSubmissionForm() {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<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
|
<PlusCircle className="h-4 w-4" /> Suggest new icon
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
@ -38,8 +38,8 @@ export function LicenseNotice() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
<p>
|
<p>
|
||||||
Unless otherwise indicated, all images and assets are the property of their respective owners and used for identification
|
All product names, trademarks, and registered trademarks are the property of their respective owners. Icons are used for
|
||||||
purposes only.
|
identification purposes only and do not imply endorsement.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Read the{" "}
|
Read the{" "}
|
||||||
|
114
web/src/components/recently-added-icons.tsx
Normal file
114
web/src/components/recently-added-icons.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -12,16 +12,26 @@ export function ThemeSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<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>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
Light
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
@ -72,3 +72,17 @@ export async function getTotalIcons() {
|
|||||||
totalIcons: Object.keys(iconsData).length,
|
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)
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user