mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-06-28 07:20:21 +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 { 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>
|
||||
)
|
||||
}
|
||||
|
@ -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 }) => (
|
||||
{filteredIcons.map(({ name, data }, index) => (
|
||||
<motion.div
|
||||
key={name}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.03,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={name}
|
||||
href={`/icons/${name}`}
|
||||
className="group flex flex-col items-center p-3 sm:p-4 rounded-lg border border-border hover:border-primary hover:bg-accent transition-colors"
|
||||
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"
|
||||
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">{name.replace(/-/g, " ")}</span>
|
||||
<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>
|
||||
)}
|
||||
|
@ -52,8 +52,34 @@ export const dynamic = "force-static"
|
||||
export default async function IconsPage() {
|
||||
const icons = await getIconsArray()
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 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]">
|
||||
<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>
|
||||
@ -64,5 +90,6 @@ export default async function IconsPage() {
|
||||
<IconSearch icons={icons} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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[],
|
||||
})),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
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 * 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" />
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
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/")
|
||||
|
||||
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>
|
||||
|
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 { 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>
|
||||
<div className="hidden md:block">
|
||||
<HeaderNav />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<CommandMenu icons={Object.keys(icons)} />
|
||||
{/* 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 />
|
||||
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary">
|
||||
<Github className="h-5 w-5" />
|
||||
<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>
|
||||
</header>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
<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>
|
||||
|
@ -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,6 +194,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
<AvatarImage src={authorData.avatar_url} alt={authorName} />
|
||||
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
|
||||
</Avatar>
|
||||
{authorData.html_url ? (
|
||||
<Link
|
||||
href={authorData.html_url}
|
||||
target="_blank"
|
||||
@ -202,6 +203,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
>
|
||||
{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>
|
||||
|
@ -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>
|
||||
|
@ -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{" "}
|
||||
|
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 (
|
||||
<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" />
|
||||
<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>
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user