mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-26 13:09:04 +08:00
Compare commits
5 Commits
refactor/c
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4946e9de55 | ||
|
|
3e2709e7a8 | ||
|
|
245033befc | ||
|
|
9949f663eb | ||
|
|
a579d41f45 |
@@ -1,5 +1,5 @@
|
||||
name: "Add light/dark icon"
|
||||
description: Submit a new icon with light and dark versions.
|
||||
name: "Add light & dark icon"
|
||||
description: Use this template to add a new icon to the project. Monochrome icons need both light and dark versions.
|
||||
title: "feat(icons): add [NAME]"
|
||||
labels: ["monochrome-icon"]
|
||||
body:
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/add_normal_icon.yml
vendored
7
.github/ISSUE_TEMPLATE/add_normal_icon.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: "Add standard icon"
|
||||
description: Submit a new icon for both light and dark themes.
|
||||
name: "Add normal icon"
|
||||
description: Use this template to add a new icon to the project. Normal icons work for both light and dark themes.
|
||||
title: "feat(icons): add [NAME]"
|
||||
labels: ["normal-icon"]
|
||||
body:
|
||||
@@ -10,16 +10,19 @@ body:
|
||||
Once you've submitted the issue, sombody from the team will review it, before adding a label which automatically creates a pull request with the other filetypes.
|
||||
If you submit a PNG icon, please note, that the SVG can not be generated from it.
|
||||
- type: input
|
||||
id: name
|
||||
attributes:
|
||||
label: Icon name
|
||||
description: The name has to be unique and should be kebab-case.
|
||||
placeholder: e.g. "icon-name"
|
||||
- type: textarea
|
||||
id: icon
|
||||
attributes:
|
||||
label: Paste icon
|
||||
description: |
|
||||
Please paste the icon here. It will automatically upload it to github.
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: Icon type
|
||||
options:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: "Update light/dark icon"
|
||||
description: Improve or update an existing light/dark icon.
|
||||
name: "Update light & dark icon"
|
||||
description: Use this template to update an existing icon. Monochrome icons need both light and dark versions.
|
||||
title: "feat(icons): update [NAME]"
|
||||
labels: ["monochrome-icon"]
|
||||
body:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: "Update standard icon"
|
||||
description: Improve or update an existing standard icon.
|
||||
name: "Update normal icon"
|
||||
description: Use this template to update an existing icon. Normal icons work for both light and dark themes.
|
||||
title: "feat(icons): update [NAME]"
|
||||
labels: ["normal-icon"]
|
||||
body:
|
||||
|
||||
@@ -32,16 +32,16 @@ export default function ErrorPage({
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Something went wrong</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Unable to load this page. We're looking into the issue.
|
||||
An unexpected error occurred while loading this page. We've been notified and are looking into it.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<Button variant="outline" onClick={() => reset()} className="cursor-pointer">
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={handleGoBack} className="cursor-pointer">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest && <p className="text-xs text-muted-foreground mt-6">Error ID: {error.digest}</p>}
|
||||
|
||||
@@ -2,12 +2,6 @@ import { readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { getAllIcons } from "@/lib/api"
|
||||
import { ImageResponse } from "next/og"
|
||||
import {
|
||||
SITE_NAME,
|
||||
SITE_TAGLINE,
|
||||
getIconDescription,
|
||||
WEB_URL
|
||||
} from "@/constants"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
@@ -58,9 +52,9 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
position: "relative",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#0f172a", // Dark background (slate-900)
|
||||
backgroundColor: "white",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)",
|
||||
"radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)",
|
||||
backgroundSize: "100px 100px",
|
||||
}}
|
||||
>
|
||||
@@ -73,7 +67,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
width: 400,
|
||||
height: 400,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)",
|
||||
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)",
|
||||
filter: "blur(80px)",
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -86,7 +80,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(234, 88, 12, 0.15) 100%)",
|
||||
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(234, 88, 12, 0.1) 100%)",
|
||||
filter: "blur(100px)",
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -115,8 +109,8 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
width: 320,
|
||||
height: 320,
|
||||
borderRadius: 32,
|
||||
background: "#1e293b", // Dark container (slate-800)
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)",
|
||||
background: "white",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)",
|
||||
padding: 30,
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
@@ -127,7 +121,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "linear-gradient(145deg, #1e293b 0%, #0f172a 100%)",
|
||||
background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
@@ -140,7 +134,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
objectFit: "contain",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.3))",
|
||||
filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -160,7 +154,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
display: "flex",
|
||||
fontSize: 64,
|
||||
fontWeight: 800,
|
||||
color: "#f8fafc", // Light text for dark background (slate-50)
|
||||
color: "#0f172a",
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
@@ -173,14 +167,14 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
display: "flex",
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
color: "#94a3b8", // Muted text (slate-400)
|
||||
color: "#64748b",
|
||||
lineHeight: 1.4,
|
||||
position: "relative",
|
||||
paddingLeft: 16,
|
||||
borderLeft: "4px solid #64748b", // slate-500
|
||||
borderLeft: "4px solid #94a3b8",
|
||||
}}
|
||||
>
|
||||
{getIconDescription(formattedIconName, totalIcons)}
|
||||
Amongst {totalIcons} other high-quality dashboard icons
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -197,14 +191,14 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#334155", // slate-700
|
||||
color: "#e2e8f0", // slate-200
|
||||
border: "2px solid #475569", // slate-600
|
||||
backgroundColor: "#f1f5f9",
|
||||
color: "#475569",
|
||||
border: "2px solid #e2e8f0",
|
||||
borderRadius: 12,
|
||||
padding: "8px 16px",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.2)",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
>
|
||||
{format}
|
||||
@@ -225,8 +219,8 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#1e293b", // slate-800
|
||||
borderTop: "2px solid rgba(255, 255, 255, 0.1)",
|
||||
background: "#ffffff",
|
||||
borderTop: "2px solid rgba(0, 0, 0, 0.05)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
@@ -235,7 +229,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
display: "flex",
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: "#e2e8f0", // slate-200
|
||||
color: "#334155",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
@@ -245,11 +239,11 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#3b82f6", // blue-500
|
||||
backgroundColor: "#3b82f6",
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
{WEB_URL.replace("https://", "")}
|
||||
dashboardicons.com
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { IconDetails } from "@/components/icon-details"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
import { BASE_URL, GITHUB_URL, ICON_DETAIL_KEYWORDS, SITE_NAME, SITE_TAGLINE, TITLE_SEPARATOR, WEB_URL, getIconDescription, getIconSchema } from "@/constants"
|
||||
import { BASE_URL, WEB_URL } from "@/constants"
|
||||
import { getAllIcons, getAuthorData } from "@/lib/api"
|
||||
import type { Metadata, ResolvingMetadata } from "next"
|
||||
import Script from "next/script"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export const dynamicParams = false
|
||||
@@ -42,39 +40,43 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
|
||||
const title = `${formattedIconName} Icon ${TITLE_SEPARATOR} ${SITE_NAME}`
|
||||
const fullTitle = `${formattedIconName} Icon ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`
|
||||
const description = getIconDescription(formattedIconName, totalIcons)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
assets: [iconImageUrl],
|
||||
category: "Icons",
|
||||
keywords: ICON_DETAIL_KEYWORDS(formattedIconName),
|
||||
category: "icons",
|
||||
keywords: [
|
||||
`${formattedIconName} icon`,
|
||||
"dashboard icon",
|
||||
"service icon",
|
||||
"application icon",
|
||||
"tool icon",
|
||||
"web dashboard",
|
||||
"app directory",
|
||||
],
|
||||
icons: {
|
||||
icon: iconImageUrl,
|
||||
},
|
||||
abstract: description,
|
||||
abstract: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
title: fullTitle,
|
||||
description,
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
type: "article",
|
||||
url: pageUrl,
|
||||
authors: [authorName],
|
||||
publishedTime: updateDate.toISOString(),
|
||||
modifiedTime: updateDate.toISOString(),
|
||||
section: "Icons",
|
||||
tags: [formattedIconName, ...ICON_DETAIL_KEYWORDS(formattedIconName)],
|
||||
tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: fullTitle,
|
||||
description,
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
images: [iconImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
@@ -85,9 +87,6 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||
webp: `${BASE_URL}/webp/${icon}.webp`,
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"revisit-after": "7 days",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,26 +100,6 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
|
||||
}
|
||||
|
||||
const authorData = await getAuthorData(originalIconData.update.author.id)
|
||||
const updateDate = new Date(originalIconData.update.timestamp)
|
||||
const authorName = authorData.name || authorData.login
|
||||
const formattedIconName = icon
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
|
||||
const imageSchema = getIconSchema(
|
||||
formattedIconName,
|
||||
icon,
|
||||
authorName,
|
||||
authorData.html_url,
|
||||
updateDate.toISOString(),
|
||||
Object.keys(iconsData).length
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<StructuredData data={imageSchema} id="image-schema" />
|
||||
<IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
|
||||
</>
|
||||
)
|
||||
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { BASE_URL } from "@/constants"
|
||||
import type { Icon, IconSearchProps } from "@/types/icons"
|
||||
import { ArrowDownAZ, ArrowUpZA, Calendar, ChevronLeft, ChevronRight, Filter, Search, SortAsc, X } from "lucide-react"
|
||||
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
@@ -27,82 +27,24 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import posthog from "posthog-js"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
|
||||
|
||||
// Get the display rows count based on viewport size
|
||||
function getDefaultRowsPerPage() {
|
||||
if (typeof window === "undefined") return 3; // Default for SSR
|
||||
|
||||
// Calculate based on viewport height and width
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
// Determine number of columns based on viewport width
|
||||
let columns = 2; // Default for small screens (sm)
|
||||
if (vw >= 1280) columns = 8; // xl breakpoint
|
||||
else if (vw >= 1024) columns = 6; // lg breakpoint
|
||||
else if (vw >= 768) columns = 4; // md breakpoint
|
||||
else if (vw >= 640) columns = 3; // sm breakpoint
|
||||
|
||||
// Calculate rows (accounting for pagination UI space)
|
||||
const rowHeight = 130; // Approximate height of each row in pixels
|
||||
const availableHeight = vh * 0.6; // 60% of viewport height
|
||||
|
||||
// Ensure at least 1 row, maximum 5 rows
|
||||
return Math.max(1, Math.min(5, Math.floor(availableHeight / rowHeight)));
|
||||
}
|
||||
|
||||
export function IconSearch({ icons }: IconSearchProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const initialQuery = searchParams.get("q")
|
||||
const initialCategories = searchParams.getAll("category")
|
||||
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
|
||||
const initialPage = Number(searchParams.get("page") || "1")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
|
||||
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
|
||||
const [currentPage, setCurrentPage] = useState(initialPage)
|
||||
const [iconsPerPage, setIconsPerPage] = useState(getDefaultRowsPerPage() * 8) // Default cols is 8 for xl screens
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false)
|
||||
|
||||
// Add resize observer to update iconsPerPage when window size changes
|
||||
useEffect(() => {
|
||||
const updateIconsPerPage = () => {
|
||||
const rows = getDefaultRowsPerPage();
|
||||
|
||||
// Determine columns based on current viewport
|
||||
const vw = window.innerWidth;
|
||||
let columns = 2; // Default for small screens
|
||||
if (vw >= 1280) columns = 8; // xl breakpoint
|
||||
else if (vw >= 1024) columns = 6; // lg breakpoint
|
||||
else if (vw >= 768) columns = 4; // md breakpoint
|
||||
else if (vw >= 640) columns = 3; // sm breakpoint
|
||||
|
||||
setIconsPerPage(rows * columns);
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
updateIconsPerPage();
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener('resize', updateIconsPerPage);
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', updateIconsPerPage);
|
||||
}, []);
|
||||
|
||||
// Reset page when search parameters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedQuery, selectedCategories, sortOption]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery)
|
||||
@@ -196,7 +138,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
|
||||
|
||||
const updateResults = useCallback(
|
||||
(query: string, categories: string[], sort: SortOption, page = 1) => {
|
||||
(query: string, categories: string[], sort: SortOption) => {
|
||||
const params = new URLSearchParams()
|
||||
if (query) params.set("q", query)
|
||||
|
||||
@@ -210,11 +152,6 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
params.set("sort", sort)
|
||||
}
|
||||
|
||||
// Add page parameter if not the first page
|
||||
if (page > 1) {
|
||||
params.set("page", page.toString())
|
||||
}
|
||||
|
||||
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
|
||||
router.push(newUrl, { scroll: false })
|
||||
},
|
||||
@@ -260,20 +197,11 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
[updateResults, searchQuery, selectedCategories],
|
||||
)
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
setCurrentPage(page);
|
||||
updateResults(searchQuery, selectedCategories, sortOption, page);
|
||||
},
|
||||
[updateResults, searchQuery, selectedCategories, sortOption],
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchQuery("")
|
||||
setSelectedCategories([])
|
||||
setSortOption("relevance")
|
||||
setCurrentPage(1)
|
||||
updateResults("", [], "relevance", 1)
|
||||
updateResults("", [], "relevance")
|
||||
}, [updateResults])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -300,11 +228,11 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
const getSortLabel = (sort: SortOption) => {
|
||||
switch (sort) {
|
||||
case "relevance":
|
||||
return "Relevance"
|
||||
return "Best match"
|
||||
case "alphabetical-asc":
|
||||
return "Name (A-Z)"
|
||||
return "A to Z"
|
||||
case "alphabetical-desc":
|
||||
return "Name (Z-A)"
|
||||
return "Z to A"
|
||||
case "newest":
|
||||
return "Newest first"
|
||||
default:
|
||||
@@ -337,7 +265,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for icons..."
|
||||
placeholder="Search icons by name, alias, or category..."
|
||||
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
@@ -349,18 +277,18 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
{/* Filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm"
|
||||
aria-label="Filter icons"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm ">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<span>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span>
|
||||
<span>Filter</span>
|
||||
{selectedCategories.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 px-1.5">
|
||||
{selectedCategories.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 sm:w-56">
|
||||
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto p-1">
|
||||
@@ -386,7 +314,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
}}
|
||||
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
|
||||
>
|
||||
Clear categories
|
||||
Clear all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -402,18 +330,18 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
|
||||
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Relevance
|
||||
Best match
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
|
||||
<ArrowDownAZ className="h-4 w-4 mr-2" />Name (A-Z)
|
||||
<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
|
||||
<ArrowUpZA className="h-4 w-4 mr-2" />Name (Z-A)
|
||||
<ArrowUpZA className="h-4 w-4 mr-2" />Z to A
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
@@ -425,15 +353,9 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
|
||||
{/* Clear all button */}
|
||||
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="flex-1 sm:flex-none cursor-pointer bg-background"
|
||||
aria-label="Reset all filters"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
<span>Reset</span>
|
||||
<span>Clear all</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -441,7 +363,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
{/* Active filter badges */}
|
||||
{selectedCategories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-sm text-muted-foreground">Selected:</span>
|
||||
<span className="text-sm text-muted-foreground">Filters:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCategories.map((category) => (
|
||||
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
|
||||
@@ -467,7 +389,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
}}
|
||||
className="text-xs h-7 px-2 cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,33 +400,27 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2>
|
||||
<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-center w-full">
|
||||
<IconSubmissionContent />
|
||||
<div className="mt-4 flex items-center gap-2 justify-center">
|
||||
<span className="text-sm text-muted-foreground">Can't submit it yourself?</span>
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsLazyRequestSubmitted(true)
|
||||
toast("Request received!", {
|
||||
description: `We've noted your request for "${searchQuery || "this icon"}". Thanks for your suggestion.`,
|
||||
})
|
||||
posthog.capture("lazy icon request", {
|
||||
query: searchQuery,
|
||||
categories: selectedCategories,
|
||||
})
|
||||
}}
|
||||
disabled={isLazyRequestSubmitted}
|
||||
>
|
||||
Request this icon
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</h2>
|
||||
</div>
|
||||
<Button
|
||||
className="cursor-pointer motion-preset-pop"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setIsLazyRequestSubmitted(true)
|
||||
toast("We hear you!", {
|
||||
description: `Okay, okay... we'll consider adding "${searchQuery || "that icon"}" just for you. 😉`,
|
||||
})
|
||||
posthog.capture("lazy icon request", {
|
||||
query: searchQuery,
|
||||
categories: selectedCategories,
|
||||
})
|
||||
}}
|
||||
disabled={isLazyRequestSubmitted}
|
||||
>
|
||||
I want this icon added but I'm too lazy to add it myself
|
||||
</Button>
|
||||
<IconSubmissionContent />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -519,14 +435,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconsGrid
|
||||
filteredIcons={filteredIcons}
|
||||
matchedAliases={matchedAliases}
|
||||
currentPage={currentPage}
|
||||
iconsPerPage={iconsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
totalIcons={filteredIcons.length}
|
||||
/>
|
||||
<IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -536,13 +445,15 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
function IconCard({
|
||||
name,
|
||||
data: iconData,
|
||||
matchedAlias,
|
||||
}: {
|
||||
name: string
|
||||
data: Icon
|
||||
matchedAlias?: string | null
|
||||
}) {
|
||||
return (
|
||||
<MagicCard className="rounded-md shadow-md cursor-pointer">
|
||||
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4">
|
||||
<MagicCard className="rounded-md shadow-md">
|
||||
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
|
||||
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
|
||||
<Image
|
||||
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
|
||||
@@ -551,9 +462,11 @@ function IconCard({
|
||||
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-500 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
|
||||
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group- dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
|
||||
{name.replace(/-/g, " ")}
|
||||
</span>
|
||||
|
||||
{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>}
|
||||
</Link>
|
||||
</MagicCard>
|
||||
)
|
||||
@@ -562,253 +475,17 @@ function IconCard({
|
||||
interface IconsGridProps {
|
||||
filteredIcons: { name: string; data: Icon }[]
|
||||
matchedAliases: Record<string, string>
|
||||
currentPage: number
|
||||
iconsPerPage: number
|
||||
onPageChange: (page: number) => void
|
||||
totalIcons: number
|
||||
}
|
||||
|
||||
function IconsGrid({ filteredIcons, matchedAliases, currentPage, iconsPerPage, onPageChange, totalIcons }: IconsGridProps) {
|
||||
// Calculate pagination values
|
||||
const totalPages = Math.ceil(totalIcons / iconsPerPage)
|
||||
const indexOfLastIcon = currentPage * iconsPerPage
|
||||
const indexOfFirstIcon = indexOfLastIcon - iconsPerPage
|
||||
const currentIcons = filteredIcons.slice(indexOfFirstIcon, indexOfLastIcon)
|
||||
|
||||
// Calculate letter ranges for each page
|
||||
const getLetterRange = (pageNum: number) => {
|
||||
if (filteredIcons.length === 0) return '';
|
||||
const start = (pageNum - 1) * iconsPerPage;
|
||||
const end = Math.min(start + iconsPerPage - 1, filteredIcons.length - 1);
|
||||
|
||||
if (start >= filteredIcons.length) return '';
|
||||
|
||||
const firstLetter = filteredIcons[start].name.charAt(0).toUpperCase();
|
||||
const lastLetter = filteredIcons[end].name.charAt(0).toUpperCase();
|
||||
|
||||
return firstLetter === lastLetter ? firstLetter : `${firstLetter} - ${lastLetter}`;
|
||||
};
|
||||
|
||||
// Get current page letter range
|
||||
const currentLetterRange = getLetterRange(currentPage);
|
||||
|
||||
// Handle direct page input
|
||||
const [pageInput, setPageInput] = useState(currentPage.toString());
|
||||
|
||||
useEffect(() => {
|
||||
setPageInput(currentPage.toString());
|
||||
}, [currentPage]);
|
||||
|
||||
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPageInput(e.target.value);
|
||||
};
|
||||
|
||||
const handlePageInputSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const pageNumber = parseInt(pageInput);
|
||||
if (!isNaN(pageNumber) && pageNumber >= 1 && pageNumber <= totalPages) {
|
||||
onPageChange(pageNumber);
|
||||
} else {
|
||||
// Reset to current page if invalid
|
||||
setPageInput(currentPage.toString());
|
||||
}
|
||||
};
|
||||
|
||||
function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPage}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2"
|
||||
>
|
||||
{currentIcons.map(({ name, data }) => (
|
||||
<IconCard key={name} name={name} data={data} />
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col gap-4 mt-8">
|
||||
{/* Mobile view: centered content */}
|
||||
<div className="text-sm text-muted-foreground text-center md:text-left md:hidden">
|
||||
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons
|
||||
{currentLetterRange && (
|
||||
<span className="ml-2 font-medium">({currentLetterRange})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop view layout */}
|
||||
<div className="hidden md:flex justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons
|
||||
{currentLetterRange && (
|
||||
<span className="ml-2 font-medium">({currentLetterRange})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Page input and total count */}
|
||||
<form onSubmit={handlePageInputSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={handlePageInputChange}
|
||||
className="w-16 h-8 text-center cursor-text"
|
||||
aria-label="Go to page"
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap">of {totalPages}</span>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8 cursor-pointer">Go</Button>
|
||||
</form>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-r-none cursor-pointer"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center overflow-hidden">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
// Show pages around current page
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
// Calculate letter range for this page
|
||||
const letterRange = getLetterRange(pageNum);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={`h-8 w-8 p-0 rounded-none relative group cursor-pointer transition-colors duration-200 ${
|
||||
pageNum === currentPage ? "font-medium" : ""
|
||||
}`}
|
||||
aria-label={`Page ${pageNum}`}
|
||||
aria-current={pageNum === currentPage ? "page" : undefined}
|
||||
>
|
||||
{pageNum}
|
||||
{letterRange && (
|
||||
<span className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-popover text-popover-foreground px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity shadow-md whitespace-nowrap">
|
||||
{letterRange}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none cursor-pointer"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only pagination layout - centered */}
|
||||
<div className="flex flex-col items-center gap-4 md:hidden">
|
||||
{/* Mobile pagination controls */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-r-none cursor-pointer"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center overflow-hidden">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
// Show pages around current page - same logic as desktop
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={`h-8 w-8 p-0 rounded-none cursor-pointer ${
|
||||
pageNum === currentPage ? "font-medium" : ""
|
||||
}`}
|
||||
aria-label={`Page ${pageNum}`}
|
||||
aria-current={pageNum === currentPage ? "page" : undefined}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none cursor-pointer"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile page input */}
|
||||
<form onSubmit={handlePageInputSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={handlePageInputChange}
|
||||
className="w-16 h-8 text-center cursor-text"
|
||||
aria-label="Go to page"
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap">of {totalPages}</span>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8 cursor-pointer">Go</Button>
|
||||
</form>
|
||||
</div>
|
||||
</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-2">
|
||||
{filteredIcons.slice(0, 120).map(({ name, data }) => (
|
||||
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} />
|
||||
))}
|
||||
</div>
|
||||
{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import { BASE_URL, BROWSE_KEYWORDS, DEFAULT_OG_IMAGE, GITHUB_URL, ORGANIZATION_NAME, ORGANIZATION_SCHEMA, SITE_NAME, SITE_TAGLINE, TITLE_SEPARATOR, WEB_URL, getBrowseDescription } from "@/constants"
|
||||
import { BASE_URL } from "@/constants"
|
||||
import { getIconsArray } from "@/lib/api"
|
||||
import type { Metadata } from "next"
|
||||
import { IconSearch } from "./components/icon-search"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const icons = await getIconsArray()
|
||||
const totalIcons = icons.length
|
||||
|
||||
const title = `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME}`
|
||||
const description = getBrowseDescription(totalIcons)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: BROWSE_KEYWORDS,
|
||||
title: "Browse Icons | Free Dashboard Icons",
|
||||
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
keywords: [
|
||||
"browse icons",
|
||||
"dashboard icons",
|
||||
"icon search",
|
||||
"service icons",
|
||||
"application icons",
|
||||
"tool icons",
|
||||
"web dashboard",
|
||||
"app directory",
|
||||
],
|
||||
openGraph: {
|
||||
title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`,
|
||||
description,
|
||||
title: "Browse Icons | Free Dashboard Icons",
|
||||
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
type: "website",
|
||||
url: `${WEB_URL}/icons`,
|
||||
images: [DEFAULT_OG_IMAGE],
|
||||
url: `${BASE_URL}/icons`,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Browse Dashboard Icons Collection",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`,
|
||||
description,
|
||||
images: [DEFAULT_OG_IMAGE.url],
|
||||
title: "Browse Icons | Free Dashboard Icons",
|
||||
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
images: ["/og-image-browse.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${WEB_URL}/icons`,
|
||||
canonical: `${BASE_URL}/icons`,
|
||||
},
|
||||
other: {
|
||||
"revisit-after": "3 days",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,38 +51,20 @@ export const dynamic = "force-static"
|
||||
|
||||
export default async function IconsPage() {
|
||||
const icons = await getIconsArray()
|
||||
|
||||
const gallerySchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ImageGallery",
|
||||
"name": `${SITE_NAME} - Browse ${icons.length} Icons - ${SITE_TAGLINE}`,
|
||||
"description": getBrowseDescription(icons.length),
|
||||
"url": `${WEB_URL}/icons`,
|
||||
"numberOfItems": icons.length,
|
||||
"creator": {
|
||||
"@type": "Organization",
|
||||
"name": ORGANIZATION_NAME,
|
||||
"url": GITHUB_URL
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StructuredData data={gallerySchema} id="gallery-schema" />
|
||||
<div className="isolate overflow-hidden">
|
||||
<div className="py-8">
|
||||
<div className="space-y-4 mb-8 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Icons</h1>
|
||||
<p className="text-muted-foreground">Search our collection of {icons.length} icons - {SITE_TAGLINE}.</p>
|
||||
</div>
|
||||
<div className="isolate overflow-hidden">
|
||||
<div className="py-8">
|
||||
<div className="space-y-4 mb-8 mx-auto max-w-7xl">
|
||||
<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>
|
||||
|
||||
<IconSearch icons={icons} />
|
||||
</div>
|
||||
|
||||
<IconSearch icons={icons} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import { PostHogProvider } from "@/components/PostHogProvider"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { HeaderWrapper } from "@/components/header-wrapper"
|
||||
import { LicenseNotice } from "@/components/license-notice"
|
||||
import { WebsiteStructuredData } from "@/components/structured-data"
|
||||
import { getTotalIcons } from "@/lib/api"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { Toaster } from "sonner"
|
||||
import "./globals.css"
|
||||
import { DEFAULT_KEYWORDS, DEFAULT_OG_IMAGE, GITHUB_URL, ORGANIZATION_NAME, ORGANIZATION_SCHEMA, SITE_NAME, SITE_TAGLINE, WEB_URL, getDescription, getWebsiteSchema, websiteFullTitle, websiteTitle } from "@/constants"
|
||||
import { getDescription, websiteTitle } from "@/constants"
|
||||
import { ThemeProvider } from "./theme-provider"
|
||||
|
||||
const inter = Inter({
|
||||
@@ -28,13 +27,12 @@ export const viewport: Viewport = {
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
const description = getDescription(totalIcons)
|
||||
|
||||
return {
|
||||
metadataBase: new URL(WEB_URL),
|
||||
metadataBase: new URL("https://dashboardicons.com"),
|
||||
title: websiteTitle,
|
||||
description,
|
||||
keywords: DEFAULT_KEYWORDS,
|
||||
description: getDescription(totalIcons),
|
||||
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
@@ -44,23 +42,33 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
googleBot: "index, follow",
|
||||
},
|
||||
openGraph: {
|
||||
siteName: SITE_NAME,
|
||||
siteName: "Dashboard Icons",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
title: websiteFullTitle,
|
||||
description,
|
||||
url: WEB_URL,
|
||||
images: [DEFAULT_OG_IMAGE],
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
url: "https://dashboardicons.com",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: websiteFullTitle,
|
||||
description,
|
||||
images: [DEFAULT_OG_IMAGE.url],
|
||||
site: "@homarr_app",
|
||||
creator: "@homarr_app",
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
applicationName: SITE_NAME,
|
||||
applicationName: "Dashboard Icons",
|
||||
appleWebApp: {
|
||||
title: SITE_NAME,
|
||||
title: "Dashboard Icons",
|
||||
statusBarStyle: "default",
|
||||
capable: true,
|
||||
},
|
||||
@@ -80,29 +88,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
authors: [{ name: ORGANIZATION_NAME, url: GITHUB_URL }],
|
||||
creator: ORGANIZATION_NAME,
|
||||
publisher: ORGANIZATION_NAME,
|
||||
category: "Icons",
|
||||
classification: "Dashboard Design Resources",
|
||||
other: {
|
||||
"revisit-after": "7 days",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
const websiteSchema = getWebsiteSchema(totalIcons)
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
||||
<PostHogProvider>
|
||||
<WebsiteStructuredData
|
||||
websiteSchema={websiteSchema}
|
||||
organizationSchema={ORGANIZATION_SCHEMA}
|
||||
/>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<HeaderWrapper />
|
||||
<main className="flex-grow">{children}</main>
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function NotFound({
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-8 h-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Not found</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Icon not found</h1>
|
||||
<p className="text-muted-foreground mt-3 max-w-md">
|
||||
This icon does not exist or could not be loaded.
|
||||
The icon you are looking for could not be found or there was an error loading it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,16 +25,16 @@ export default function NotFound({
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/icons">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to icons
|
||||
Back to all icons
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-8 mt-8">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Missing an icon?</h2>
|
||||
<h2 className="text-xl font-semibold">Can't find what you're looking for?</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Submit a new icon or suggest improvements to our collection.
|
||||
Contribute to our icon collection by suggesting a new icon or improving an existing one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import { HeroSection } from "@/components/hero"
|
||||
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
import { BASE_URL, DEFAULT_KEYWORDS, DEFAULT_OG_IMAGE, GITHUB_URL, ORGANIZATION_NAME, ORGANIZATION_SCHEMA, SITE_NAME, SITE_TAGLINE, WEB_URL, REPO_NAME, getHomeDescription, websiteFullTitle, websiteTitle } from "@/constants"
|
||||
import { BASE_URL, REPO_NAME, getDescription, websiteTitle } from "@/constants"
|
||||
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
const description = getHomeDescription(totalIcons)
|
||||
|
||||
return {
|
||||
title: websiteTitle,
|
||||
description,
|
||||
keywords: DEFAULT_KEYWORDS,
|
||||
description: getDescription(totalIcons),
|
||||
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
title: websiteFullTitle,
|
||||
description,
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
type: "website",
|
||||
url: WEB_URL,
|
||||
images: [DEFAULT_OG_IMAGE],
|
||||
url: BASE_URL,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: websiteFullTitle,
|
||||
description,
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
card: "summary_large_image",
|
||||
images: [DEFAULT_OG_IMAGE.url],
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: WEB_URL,
|
||||
canonical: BASE_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -49,11 +54,9 @@ export default async function Home() {
|
||||
const stars = await getGitHubStars()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<HeroSection totalIcons={totalIcons} stars={stars} />
|
||||
<RecentlyAddedIcons icons={recentIcons} />
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<HeroSection totalIcons={totalIcons} stars={stars} />
|
||||
<RecentlyAddedIcons icons={recentIcons} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function Carbon() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const serve = "CW7IP27L"
|
||||
const serve = "CW7IKKQM"
|
||||
const placement = "dashboardiconscom"
|
||||
ref.current.innerHTML = ""
|
||||
const s = document.createElement("script")
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Footer() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Collection of icons for applications, services, and tools - designed for dashboards and app directories.
|
||||
A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -205,61 +205,13 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8 mt-4 py-20">
|
||||
<div className="relative z-10 container mx-auto px-4 md:px-6 mt-4 py-20">
|
||||
<div className="max-w-4xl mx-auto text-center flex flex-col gap-4 ">
|
||||
<h1 className="relative text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight motion-preset-slide-up motion-duration-500 ">
|
||||
Your definitive source for
|
||||
<motion.span
|
||||
className="absolute -right-1 -bottom-3"
|
||||
initial={{ opacity: 0, scale: 0.5, x: -20, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.3,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [0, -3, 0],
|
||||
rotate: [0, 5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse",
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<Sparkles className="text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12" />
|
||||
</motion.div>
|
||||
</motion.span>
|
||||
<Sparkles className="absolute -right-1 -bottom-3 text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
|
||||
<br />
|
||||
<motion.span
|
||||
className="absolute -left-1 -top-3"
|
||||
initial={{ opacity: 0, scale: 0.5, x: 20, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.3,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [0, -3, 0],
|
||||
rotate: [0, -5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse",
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<Sparkles className="text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12" />
|
||||
</motion.div>
|
||||
</motion.span>
|
||||
<Sparkles className="absolute -left-1 -top-3 text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[159%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
|
||||
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
||||
</h1>
|
||||
|
||||
@@ -272,7 +224,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
|
||||
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
|
||||
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-500">
|
||||
<Link href="/icons">
|
||||
<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton>
|
||||
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
|
||||
</Link>
|
||||
<GiveUsAStarButton stars={stars} />
|
||||
<GiveUsMoneyButton />
|
||||
@@ -497,12 +449,12 @@ export function GiveUsMoneyButton() {
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90">
|
||||
Support
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-secondary-foreground">
|
||||
View transactions
|
||||
View expenses
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -526,7 +478,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro
|
||||
name="q"
|
||||
autoFocus
|
||||
type="search"
|
||||
placeholder="Search for icons..."
|
||||
placeholder={`Find any of ${totalIcons} icons by name or category...`}
|
||||
className="pl-10 h-10 md:h-12 rounded-lg w-full border-border focus:border-primary/20 text-sm md:text-base"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
@@ -207,7 +207,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg cursor-pointer"
|
||||
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
|
||||
aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -224,7 +223,6 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg cursor-pointer"
|
||||
onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
|
||||
aria-label={`Copy URL for ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||
>
|
||||
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
@@ -236,18 +234,8 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View ${iconName} ${format} file on GitHub`}
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
@@ -264,7 +252,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
||||
<div className="container mx-auto pt-12 pb-14">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Left Column: Icon Info and Author */}
|
||||
<div className="lg:col-span-1">
|
||||
@@ -318,7 +306,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
{iconData.categories && iconData.categories.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{iconData.categories.map((category) => (
|
||||
<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer">
|
||||
@@ -339,7 +327,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
{iconData.aliases && iconData.aliases.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{iconData.aliases.map((alias) => (
|
||||
<Badge
|
||||
@@ -356,17 +344,19 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">About this icon</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">About this icon</h3>
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
Available in {availableFormats.length > 1
|
||||
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
|
||||
: `${availableFormats[0].toUpperCase()} format `}
|
||||
Available in{" "}
|
||||
{availableFormats.length > 1
|
||||
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
|
||||
: `${availableFormats[0].toUpperCase()} format`}{" "}
|
||||
with a base format of {iconData.base.toUpperCase()}.
|
||||
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
|
||||
</p>
|
||||
<p>
|
||||
Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")} logo.
|
||||
Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user
|
||||
experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,7 +412,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base format</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileType className="w-4 h-4 text-blue-500" />
|
||||
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">{iconData.base.toUpperCase()}</div>
|
||||
@@ -430,7 +420,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Available formats</h3>
|
||||
<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 border border-border rounded-lg text-xs font-medium">
|
||||
@@ -442,7 +432,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
{iconData.colors && (
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Color variants</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(iconData.colors).map(([theme, variant]) => (
|
||||
<div key={theme} className="flex items-center gap-2">
|
||||
@@ -456,7 +446,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
)}
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Source</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -11,32 +11,32 @@ import { useState } from "react"
|
||||
export const ISSUE_TEMPLATES = [
|
||||
{
|
||||
id: "add_monochrome_icon",
|
||||
name: "Add light/dark icon",
|
||||
description: "Submit a new icon with light and dark versions.",
|
||||
name: "Add light & dark icon",
|
||||
description: "Submit a new icon with both light and dark versions for optimal theme compatibility.",
|
||||
url: `${REPO_PATH}/issues/new?template=add_monochrome_icon.yml`,
|
||||
},
|
||||
{
|
||||
id: "add_normal_icon",
|
||||
name: "Add standard icon",
|
||||
description: "Submit a new icon for both themes.",
|
||||
name: "Add normal icon",
|
||||
description: "Submit a new icon that works well across both light and dark themes.",
|
||||
url: `${REPO_PATH}/issues/new?template=add_normal_icon.yml`,
|
||||
},
|
||||
{
|
||||
id: "update_monochrome_icon",
|
||||
name: "Update light/dark icon",
|
||||
description: "Improve or update an existing light/dark icon.",
|
||||
name: "Update light & dark icon",
|
||||
description: "Improve an existing icon by updating both light and dark versions.",
|
||||
url: `${REPO_PATH}/issues/new?template=update_monochrome_icon.yml`,
|
||||
},
|
||||
{
|
||||
id: "update_normal_icon",
|
||||
name: "Update standard icon",
|
||||
description: "Improve or update an existing standard icon.",
|
||||
name: "Update normal icon",
|
||||
description: "Improve an existing icon that works across both light and dark themes.",
|
||||
url: `${REPO_PATH}/issues/new?template=update_normal_icon.yml`,
|
||||
},
|
||||
{
|
||||
id: "blank_issue",
|
||||
name: "Other request",
|
||||
description: "Submit another type of request.",
|
||||
name: "Something else",
|
||||
description: "Create a custom issue for other suggestions, bug reports, or improvements.",
|
||||
url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`,
|
||||
},
|
||||
]
|
||||
@@ -73,13 +73,13 @@ export function IconSubmissionForm() {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300">
|
||||
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
|
||||
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Contribute new icon
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit an icon</DialogTitle>
|
||||
<DialogDescription>Select an option below to submit or update an icon.</DialogDescription>
|
||||
<DialogTitle>Contribute a new icon</DialogTitle>
|
||||
<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<IconSubmissionContent onClose={() => setOpen(false)} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -28,7 +28,6 @@ export function MagicCard({
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const mouseX = useMotionValue(-gradientSize)
|
||||
const mouseY = useMotionValue(-gradientSize)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
@@ -61,14 +60,6 @@ export function MagicCard({
|
||||
}, [handleMouseMove, mouseX, gradientSize, mouseY])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
mouseX.set(-gradientSize)
|
||||
mouseY.set(-gradientSize)
|
||||
}, [gradientSize, mouseX, mouseY])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseout", handleMouseOut)
|
||||
document.addEventListener("mouseenter", handleMouseEnter)
|
||||
@@ -78,17 +69,22 @@ export function MagicCard({
|
||||
document.removeEventListener("mouseout", handleMouseOut)
|
||||
document.removeEventListener("mouseenter", handleMouseEnter)
|
||||
}
|
||||
}, [isMounted, handleMouseEnter, handleMouseMove, handleMouseOut])
|
||||
}, [handleMouseEnter, handleMouseMove, handleMouseOut])
|
||||
|
||||
useEffect(() => {
|
||||
mouseX.set(-gradientSize)
|
||||
mouseY.set(-gradientSize)
|
||||
}, [gradientSize, mouseX, mouseY])
|
||||
|
||||
return (
|
||||
<div className={cn("group relative rounded-[inherit]", className)}>
|
||||
<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||
${gradientFrom},
|
||||
${gradientTo},
|
||||
${gradientFrom},
|
||||
${gradientTo},
|
||||
var(--border) 100%
|
||||
)
|
||||
`,
|
||||
@@ -104,7 +100,7 @@ export function MagicCard({
|
||||
opacity: gradientOpacity,
|
||||
}}
|
||||
/>
|
||||
<div ref={cardRef} className="relative">{children}</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
|
||||
{/* 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="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl text-center my-4">
|
||||
<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 motion-safe:motion-preset-fade-lg motion-duration-500">
|
||||
Recently Added Icons
|
||||
@@ -61,7 +61,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
|
||||
href="/icons"
|
||||
className="font-medium inline-flex items-center py-2 px-4 rounded-full border transition-all duration-200 group hover-lift soft-shadow"
|
||||
>
|
||||
View all icons
|
||||
View complete collection
|
||||
<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
type StructuredDataProps = {
|
||||
data: any
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const StructuredData = ({ data, id }: StructuredDataProps) => {
|
||||
return (
|
||||
<script
|
||||
id={id}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type WebsiteStructuredDataProps = {
|
||||
websiteSchema: any
|
||||
organizationSchema: any
|
||||
}
|
||||
|
||||
export const WebsiteStructuredData = ({
|
||||
websiteSchema,
|
||||
organizationSchema
|
||||
}: WebsiteStructuredDataProps) => {
|
||||
return (
|
||||
<>
|
||||
<StructuredData data={websiteSchema} id="website-schema" />
|
||||
<StructuredData data={organizationSchema} id="organization-schema" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,119 +4,7 @@ export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashb
|
||||
export const WEB_URL = "https://dashboardicons.com"
|
||||
export const REPO_NAME = "homarr-labs/dashboard-icons"
|
||||
|
||||
// Site-wide metadata constants
|
||||
export const SITE_NAME = "Dashboard Icons"
|
||||
export const TITLE_SEPARATOR = " | "
|
||||
export const SITE_TAGLINE = "Your definitive source for dashboard icons"
|
||||
export const ORGANIZATION_NAME = "Homarr Labs"
|
||||
|
||||
export const getDescription = (totalIcons: number) =>
|
||||
`A curated collection of ${totalIcons} free icons for dashboards and app directories. Available in SVG, PNG, and WEBP formats. ${SITE_TAGLINE}.`
|
||||
`A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`
|
||||
|
||||
export const getHomeDescription = (totalIcons: number) =>
|
||||
`Discover our curated collection of ${totalIcons} icons designed specifically for dashboards and app directories. ${SITE_TAGLINE}.`
|
||||
|
||||
export const getBrowseDescription = (totalIcons: number) =>
|
||||
`Browse, search and download from our collection of ${totalIcons} curated icons. All icons available in SVG, PNG, and WEBP formats. ${SITE_TAGLINE}.`
|
||||
|
||||
export const getIconDescription = (iconName: string, totalIcons: number) =>
|
||||
`Download the ${iconName} icon in SVG, PNG, and WEBP formats. Part of our curated collection of ${totalIcons} free icons for dashboards. ${SITE_TAGLINE}.`
|
||||
|
||||
export const websiteTitle = `${SITE_NAME} ${TITLE_SEPARATOR} Free, Curated Icons for Apps & Services`
|
||||
export const websiteFullTitle = `${SITE_NAME} ${TITLE_SEPARATOR} Free, Curated Icons for Apps & Services ${TITLE_SEPARATOR} ${SITE_TAGLINE}`
|
||||
|
||||
// Various keyword sets for different pages
|
||||
export const DEFAULT_KEYWORDS = [
|
||||
"dashboard icons",
|
||||
"app icons",
|
||||
"service icons",
|
||||
"curated icons",
|
||||
"free icons",
|
||||
"SVG icons",
|
||||
"web dashboard",
|
||||
"app directory"
|
||||
]
|
||||
|
||||
export const BROWSE_KEYWORDS = [
|
||||
"browse icons",
|
||||
"search icons",
|
||||
"download icons",
|
||||
"minimal icons",
|
||||
"dashboard design",
|
||||
"UI icons",
|
||||
...DEFAULT_KEYWORDS
|
||||
]
|
||||
|
||||
export const ICON_DETAIL_KEYWORDS = (iconName: string) => [
|
||||
`${iconName} icon`,
|
||||
`${iconName} logo`,
|
||||
`${iconName} svg`,
|
||||
`${iconName} download`,
|
||||
`${iconName} dashboard icon`,
|
||||
...DEFAULT_KEYWORDS
|
||||
]
|
||||
|
||||
// Core structured data for the website (JSON-LD)
|
||||
export const getWebsiteSchema = (totalIcons: number) => ({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": SITE_NAME,
|
||||
"url": WEB_URL,
|
||||
"description": getDescription(totalIcons),
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": `${WEB_URL}/icons?q={search_term_string}`
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"slogan": SITE_TAGLINE
|
||||
})
|
||||
|
||||
// Organization schema
|
||||
export const ORGANIZATION_SCHEMA = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": ORGANIZATION_NAME,
|
||||
"url": `https://github.com/${REPO_NAME}`,
|
||||
"logo": `${WEB_URL}/og-image.png`,
|
||||
"sameAs": [
|
||||
`https://github.com/${REPO_NAME}`,
|
||||
"https://homarr.dev"
|
||||
],
|
||||
"slogan": SITE_TAGLINE
|
||||
}
|
||||
|
||||
// Social media
|
||||
export const GITHUB_URL = `https://github.com/${REPO_NAME}`
|
||||
|
||||
// Image schemas
|
||||
export const getIconSchema = (iconName: string, iconId: string, authorName: string, authorUrl: string, updateDate: string, totalIcons: number) => ({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ImageObject",
|
||||
"name": `${iconName} Icon`,
|
||||
"description": getIconDescription(iconName, totalIcons),
|
||||
"contentUrl": `${BASE_URL}/png/${iconId}.png`,
|
||||
"thumbnailUrl": `${BASE_URL}/png/${iconId}.png`,
|
||||
"uploadDate": updateDate,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": authorName,
|
||||
"url": authorUrl
|
||||
},
|
||||
"encodingFormat": ["image/png", "image/svg+xml", "image/webp"],
|
||||
"contentSize": "Variable",
|
||||
"representativeOfPage": true,
|
||||
"creditText": `Icon contributed by ${authorName} to the ${SITE_NAME} collection by ${ORGANIZATION_NAME}`,
|
||||
"embedUrl": `${WEB_URL}/icons/${iconId}`
|
||||
})
|
||||
|
||||
// OpenGraph defaults
|
||||
export const DEFAULT_OG_IMAGE = {
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${SITE_NAME} - ${SITE_TAGLINE}`,
|
||||
type: "image/png"
|
||||
}
|
||||
export const websiteTitle = "Free Dashboard Icons - Download High-Quality UI & App Icons"
|
||||
|
||||
Reference in New Issue
Block a user