mirror of
				https://github.com/walkxcode/dashboard-icons.git
				synced 2025-10-26 21:19:04 +08:00 
			
		
		
		
	Compare commits
	
		
			16 Commits
		
	
	
		
			feat/new-b
			...
			refactor/c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 2ca5a2ec97 | ||
|   | 58dae5609b | ||
|   | 30c130e4e7 | ||
|   | ce16ac85d4 | ||
|   | cedbca1869 | ||
|   | e9a4880908 | ||
|   | 2f82c53b28 | ||
|   | 920e98dc66 | ||
|   | 889db39ab3 | ||
|   | 78b1aec82c | ||
|   | 8848e0c8fe | ||
|   | 81607c5690 | ||
|   | 07c52fa9e6 | ||
|   | b4c4fe2634 | ||
|   | df3c53818a | ||
|   | d6cb15aab0 | 
| @@ -1,5 +1,5 @@ | ||||
| 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. | ||||
| name: "Add light/dark icon" | ||||
| description: Submit a new icon with light and dark versions. | ||||
| title: "feat(icons): add [NAME]" | ||||
| labels: ["monochrome-icon"] | ||||
| body: | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/ISSUE_TEMPLATE/add_normal_icon.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/ISSUE_TEMPLATE/add_normal_icon.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,5 @@ | ||||
| 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. | ||||
| name: "Add standard icon" | ||||
| description: Submit a new icon for both light and dark themes. | ||||
| title: "feat(icons): add [NAME]" | ||||
| labels: ["normal-icon"] | ||||
| body: | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| name: "Update light & dark icon" | ||||
| description: Use this template to update an existing icon. Monochrome icons need both light and dark versions. | ||||
| name: "Update light/dark icon" | ||||
| description: Improve or update an existing light/dark icon. | ||||
| title: "feat(icons): update [NAME]" | ||||
| labels: ["monochrome-icon"] | ||||
| body: | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| name: "Update normal icon" | ||||
| description: Use this template to update an existing icon. Normal icons work for both light and dark themes. | ||||
| name: "Update standard icon" | ||||
| description: Improve or update an existing standard icon. | ||||
| title: "feat(icons): update [NAME]" | ||||
| labels: ["normal-icon"] | ||||
| body: | ||||
|   | ||||
| @@ -42,7 +42,6 @@ | ||||
| 		"canvas-confetti": "^1.9.3", | ||||
| 		"class-variance-authority": "^0.7.1", | ||||
| 		"clsx": "^2.1.1", | ||||
| 		"cmdk": "^1.1.1", | ||||
| 		"date-fns": "^4.1.0", | ||||
| 		"embla-carousel-react": "^8.6.0", | ||||
| 		"framer-motion": "^12.7.3", | ||||
|   | ||||
							
								
								
									
										249
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										249
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -101,9 +101,6 @@ importers: | ||||
|       clsx: | ||||
|         specifier: ^2.1.1 | ||||
|         version: 2.1.1 | ||||
|       cmdk: | ||||
|         specifier: ^1.1.1 | ||||
|         version: 1.1.1(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       date-fns: | ||||
|         specifier: ^4.1.0 | ||||
|         version: 4.1.0 | ||||
| @@ -160,7 +157,7 @@ importers: | ||||
|         version: 3.2.0 | ||||
|       tailwindcss-motion: | ||||
|         specifier: ^1.1.0 | ||||
|         version: 1.1.0(tailwindcss@4.1.3) | ||||
|         version: 1.1.0(tailwindcss@4.1.4) | ||||
|       tw-animate-css: | ||||
|         specifier: ^1.2.5 | ||||
|         version: 1.2.5 | ||||
| @@ -176,13 +173,13 @@ importers: | ||||
|         version: 1.9.4 | ||||
|       '@tailwindcss/postcss': | ||||
|         specifier: ^4.1.3 | ||||
|         version: 4.1.3 | ||||
|         version: 4.1.4 | ||||
|       '@types/canvas-confetti': | ||||
|         specifier: ^1.9.0 | ||||
|         version: 1.9.0 | ||||
|       '@types/node': | ||||
|         specifier: ^22.14.0 | ||||
|         version: 22.14.0 | ||||
|         version: 22.14.1 | ||||
|       '@types/react': | ||||
|         specifier: ^19.1.0 | ||||
|         version: 19.1.0 | ||||
| @@ -191,13 +188,13 @@ importers: | ||||
|         version: 19.1.2(@types/react@19.1.0) | ||||
|       tailwindcss: | ||||
|         specifier: ^4.1.3 | ||||
|         version: 4.1.3 | ||||
|         version: 4.1.4 | ||||
|       typescript: | ||||
|         specifier: ^5.8.3 | ||||
|         version: 5.8.3 | ||||
|       wrangler: | ||||
|         specifier: ^4.12.0 | ||||
|         version: 4.12.0 | ||||
|         version: 4.12.1 | ||||
|  | ||||
| packages: | ||||
|  | ||||
| @@ -275,32 +272,32 @@ packages: | ||||
|       workerd: | ||||
|         optional: true | ||||
|  | ||||
|   '@cloudflare/workerd-darwin-64@1.20250416.0': | ||||
|     resolution: {integrity: sha512-aZgF8Swp9eVYxJPWOoZbAgAaYjWuYqGmEA+QJ2ecRGDBqm87rT4GEw7/mmLpxrpllny3VfEEhkk9iYCGv8nlFw==} | ||||
|   '@cloudflare/workerd-darwin-64@1.20250417.0': | ||||
|     resolution: {integrity: sha512-4Adfl92aKepjxb8e6af2d+xpD2sBOADgHqvkyXsFmoLb80weMEDDRGJi1p1m5q1M78/oVnGcpdmuRCAathanRg==} | ||||
|     engines: {node: '>=16'} | ||||
|     cpu: [x64] | ||||
|     os: [darwin] | ||||
|  | ||||
|   '@cloudflare/workerd-darwin-arm64@1.20250416.0': | ||||
|     resolution: {integrity: sha512-FhswG1QYRfaTZ4FAlUkfVWaoM2lrlqumiBTrhbo9czMJdGR/oBXS4SGynuI6zyhApHeBf3/fZpA/SBAe4cXdgg==} | ||||
|   '@cloudflare/workerd-darwin-arm64@1.20250417.0': | ||||
|     resolution: {integrity: sha512-dSlk18F4i3T1OTzFBxx3pKpXRMP6w2xZ26+oIV32BFWrCi/HxGzUd6gVA0q37oLGqITRt8xU693J4Gl1CwC/Ag==} | ||||
|     engines: {node: '>=16'} | ||||
|     cpu: [arm64] | ||||
|     os: [darwin] | ||||
|  | ||||
|   '@cloudflare/workerd-linux-64@1.20250416.0': | ||||
|     resolution: {integrity: sha512-G+nXEAJ/9y+A857XShwxKeRdfxok6UcjiQe6G+wQeCn/Ofkp/EWydacKdyeVU6QIm1oHS78DwJ7AzbCYywf9aw==} | ||||
|   '@cloudflare/workerd-linux-64@1.20250417.0': | ||||
|     resolution: {integrity: sha512-27MVzOa/lENcqewC2L9EcqstXW843UhjBMcwV1umDfsjwLyZOEv6Gtm/6j5r0L0gASvkRTam3fAmtPk/gt48TA==} | ||||
|     engines: {node: '>=16'} | ||||
|     cpu: [x64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@cloudflare/workerd-linux-arm64@1.20250416.0': | ||||
|     resolution: {integrity: sha512-U6oVW0d9w1fpnDYNrjPJ9SFkDlGJWJWbXHlTBObXl6vccP16WewvuxyHkKqyUhUc8hyBaph7sxeKzKmuCFQ4SA==} | ||||
|   '@cloudflare/workerd-linux-arm64@1.20250417.0': | ||||
|     resolution: {integrity: sha512-34qBk0htAXmUneOTQxW6/g6pjNVR91r0vJzz2FID84cAIOYVl4hZLijkjmVl+MMDU6boXUs+yDwhItdg06YvAg==} | ||||
|     engines: {node: '>=16'} | ||||
|     cpu: [arm64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@cloudflare/workerd-windows-64@1.20250416.0': | ||||
|     resolution: {integrity: sha512-YAjjTzL1z9YYeN4sqYfj1dtQXd2Bblj+B+hl4Rz2aOhblpZEZAdhapZlOCRvLLkOJshKJUnRD3mDlytAdgwybQ==} | ||||
|   '@cloudflare/workerd-windows-64@1.20250417.0': | ||||
|     resolution: {integrity: sha512-PDwATFioff+geVHfgTzSWsxgwjgotrdXStb0EL0lMyMT5zNmHArAnOx83CbDtud63Uv9rVX1BAfPP4tyD1O+5A==} | ||||
|     engines: {node: '>=16'} | ||||
|     cpu: [x64] | ||||
|     os: [win32] | ||||
| @@ -1365,81 +1362,93 @@ packages: | ||||
|   '@swc/helpers@0.5.15': | ||||
|     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | ||||
|  | ||||
|   '@tailwindcss/node@4.1.3': | ||||
|     resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==} | ||||
|   '@tailwindcss/node@4.1.4': | ||||
|     resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} | ||||
|  | ||||
|   '@tailwindcss/oxide-android-arm64@4.1.3': | ||||
|     resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==} | ||||
|   '@tailwindcss/oxide-android-arm64@4.1.4': | ||||
|     resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm64] | ||||
|     os: [android] | ||||
|  | ||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.3': | ||||
|     resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==} | ||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.4': | ||||
|     resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm64] | ||||
|     os: [darwin] | ||||
|  | ||||
|   '@tailwindcss/oxide-darwin-x64@4.1.3': | ||||
|     resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==} | ||||
|   '@tailwindcss/oxide-darwin-x64@4.1.4': | ||||
|     resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [x64] | ||||
|     os: [darwin] | ||||
|  | ||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.3': | ||||
|     resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==} | ||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.4': | ||||
|     resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [x64] | ||||
|     os: [freebsd] | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3': | ||||
|     resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==} | ||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': | ||||
|     resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.3': | ||||
|     resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==} | ||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': | ||||
|     resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.3': | ||||
|     resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==} | ||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.4': | ||||
|     resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.3': | ||||
|     resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==} | ||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.4': | ||||
|     resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [x64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.3': | ||||
|     resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==} | ||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.4': | ||||
|     resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [x64] | ||||
|     os: [linux] | ||||
|  | ||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': | ||||
|     resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==} | ||||
|   '@tailwindcss/oxide-wasm32-wasi@4.1.4': | ||||
|     resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} | ||||
|     engines: {node: '>=14.0.0'} | ||||
|     cpu: [wasm32] | ||||
|     bundledDependencies: | ||||
|       - '@napi-rs/wasm-runtime' | ||||
|       - '@emnapi/core' | ||||
|       - '@emnapi/runtime' | ||||
|       - '@tybys/wasm-util' | ||||
|       - '@emnapi/wasi-threads' | ||||
|       - tslib | ||||
|  | ||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': | ||||
|     resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [arm64] | ||||
|     os: [win32] | ||||
|  | ||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.3': | ||||
|     resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==} | ||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.4': | ||||
|     resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} | ||||
|     engines: {node: '>= 10'} | ||||
|     cpu: [x64] | ||||
|     os: [win32] | ||||
|  | ||||
|   '@tailwindcss/oxide@4.1.3': | ||||
|     resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==} | ||||
|   '@tailwindcss/oxide@4.1.4': | ||||
|     resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} | ||||
|     engines: {node: '>= 10'} | ||||
|  | ||||
|   '@tailwindcss/postcss@4.1.3': | ||||
|     resolution: {integrity: sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg==} | ||||
|   '@tailwindcss/postcss@4.1.4': | ||||
|     resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==} | ||||
|  | ||||
|   '@tanstack/react-virtual@3.13.6': | ||||
|     resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} | ||||
| @@ -1480,8 +1489,8 @@ packages: | ||||
|   '@types/d3-timer@3.0.2': | ||||
|     resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} | ||||
|  | ||||
|   '@types/node@22.14.0': | ||||
|     resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} | ||||
|   '@types/node@22.14.1': | ||||
|     resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} | ||||
|  | ||||
|   '@types/react-dom@19.1.2': | ||||
|     resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} | ||||
| @@ -1540,12 +1549,6 @@ packages: | ||||
|     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | ||||
|     engines: {node: '>=6'} | ||||
|  | ||||
|   cmdk@1.1.1: | ||||
|     resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} | ||||
|     peerDependencies: | ||||
|       react: ^18 || ^19 || ^19.0.0-rc | ||||
|       react-dom: ^18 || ^19 || ^19.0.0-rc | ||||
|  | ||||
|   color-convert@2.0.1: | ||||
|     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | ||||
|     engines: {node: '>=7.0.0'} | ||||
| @@ -1888,8 +1891,8 @@ packages: | ||||
|     engines: {node: '>=10.0.0'} | ||||
|     hasBin: true | ||||
|  | ||||
|   miniflare@4.20250416.0: | ||||
|     resolution: {integrity: sha512-261PhPgD9zs5/BTdbWqwiaXtWxb+Av5zKCwTU+HXrA5E4tf3qnULwh3u6SVUOAEArEroFuKJzawsQ9COtNBurQ==} | ||||
|   miniflare@4.20250417.0: | ||||
|     resolution: {integrity: sha512-bROKLQKr4CoS93tnGuw5e08VaNwM3VowTL3Z2Cps1HzY6a4Bq8uNtggQ7WogriMq77jcHn6kbz64bvWyF//Jkw==} | ||||
|     engines: {node: '>=18.0.0'} | ||||
|     hasBin: true | ||||
|  | ||||
| @@ -2153,8 +2156,8 @@ packages: | ||||
|     peerDependencies: | ||||
|       tailwindcss: '>=3.0.0 || insiders' | ||||
|  | ||||
|   tailwindcss@4.1.3: | ||||
|     resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==} | ||||
|   tailwindcss@4.1.4: | ||||
|     resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} | ||||
|  | ||||
|   tapable@2.2.1: | ||||
|     resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} | ||||
| @@ -2219,17 +2222,17 @@ packages: | ||||
|   web-vitals@4.2.4: | ||||
|     resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} | ||||
|  | ||||
|   workerd@1.20250416.0: | ||||
|     resolution: {integrity: sha512-Yrx/bZAKbmSvomdTAzzIpOHwpYhs0ldr2wqed22UEhQ0mIplAHY4xmY+SjAJhP/TydZrciOVzBxwM1+4T40KNA==} | ||||
|   workerd@1.20250417.0: | ||||
|     resolution: {integrity: sha512-naz6oJiVODd3/Lkp9l3vtc56HKOOvx+AWDvEsTa5eSfi5SI9V0HYpLYSPblAwrfazbQ4ff1Vl3jkTl/5JxqCAA==} | ||||
|     engines: {node: '>=16'} | ||||
|     hasBin: true | ||||
|  | ||||
|   wrangler@4.12.0: | ||||
|     resolution: {integrity: sha512-4rfAXOi5KqM3ECvOrZJ97k3zEqxVwtdt4bijd8jcRBZ6iJYvEtjgjVi4TsfkVa/eXGhpfHTUnKu2uk8UHa8M2w==} | ||||
|   wrangler@4.12.1: | ||||
|     resolution: {integrity: sha512-jYrz8y2ffhsRqvQLO2dXFi9HLvPUJk3jn7U71GWfBBCHm0I6r2ik7Vs9ajpRcTGlbNw1RY0uIHVJBVR/7bEN5A==} | ||||
|     engines: {node: '>=18.0.0'} | ||||
|     hasBin: true | ||||
|     peerDependencies: | ||||
|       '@cloudflare/workers-types': ^4.20250415.0 | ||||
|       '@cloudflare/workers-types': ^4.20250417.0 | ||||
|     peerDependenciesMeta: | ||||
|       '@cloudflare/workers-types': | ||||
|         optional: true | ||||
| @@ -2302,25 +2305,25 @@ snapshots: | ||||
|     dependencies: | ||||
|       mime: 3.0.0 | ||||
|  | ||||
|   '@cloudflare/unenv-preset@2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250416.0)': | ||||
|   '@cloudflare/unenv-preset@2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250417.0)': | ||||
|     dependencies: | ||||
|       unenv: 2.0.0-rc.15 | ||||
|     optionalDependencies: | ||||
|       workerd: 1.20250416.0 | ||||
|       workerd: 1.20250417.0 | ||||
|  | ||||
|   '@cloudflare/workerd-darwin-64@1.20250416.0': | ||||
|   '@cloudflare/workerd-darwin-64@1.20250417.0': | ||||
|     optional: true | ||||
|  | ||||
|   '@cloudflare/workerd-darwin-arm64@1.20250416.0': | ||||
|   '@cloudflare/workerd-darwin-arm64@1.20250417.0': | ||||
|     optional: true | ||||
|  | ||||
|   '@cloudflare/workerd-linux-64@1.20250416.0': | ||||
|   '@cloudflare/workerd-linux-64@1.20250417.0': | ||||
|     optional: true | ||||
|  | ||||
|   '@cloudflare/workerd-linux-arm64@1.20250416.0': | ||||
|   '@cloudflare/workerd-linux-arm64@1.20250417.0': | ||||
|     optional: true | ||||
|  | ||||
|   '@cloudflare/workerd-windows-64@1.20250416.0': | ||||
|   '@cloudflare/workerd-windows-64@1.20250417.0': | ||||
|     optional: true | ||||
|  | ||||
|   '@cspotcode/source-map-support@0.8.1': | ||||
| @@ -3264,67 +3267,71 @@ snapshots: | ||||
|     dependencies: | ||||
|       tslib: 2.8.1 | ||||
|  | ||||
|   '@tailwindcss/node@4.1.3': | ||||
|   '@tailwindcss/node@4.1.4': | ||||
|     dependencies: | ||||
|       enhanced-resolve: 5.18.1 | ||||
|       jiti: 2.4.2 | ||||
|       lightningcss: 1.29.2 | ||||
|       tailwindcss: 4.1.3 | ||||
|       tailwindcss: 4.1.4 | ||||
|  | ||||
|   '@tailwindcss/oxide-android-arm64@4.1.3': | ||||
|   '@tailwindcss/oxide-android-arm64@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.3': | ||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-darwin-x64@4.1.3': | ||||
|   '@tailwindcss/oxide-darwin-x64@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.3': | ||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3': | ||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.3': | ||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.3': | ||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.3': | ||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.3': | ||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': | ||||
|   '@tailwindcss/oxide-wasm32-wasi@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.3': | ||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide@4.1.3': | ||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.4': | ||||
|     optional: true | ||||
|  | ||||
|   '@tailwindcss/oxide@4.1.4': | ||||
|     optionalDependencies: | ||||
|       '@tailwindcss/oxide-android-arm64': 4.1.3 | ||||
|       '@tailwindcss/oxide-darwin-arm64': 4.1.3 | ||||
|       '@tailwindcss/oxide-darwin-x64': 4.1.3 | ||||
|       '@tailwindcss/oxide-freebsd-x64': 4.1.3 | ||||
|       '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3 | ||||
|       '@tailwindcss/oxide-linux-arm64-gnu': 4.1.3 | ||||
|       '@tailwindcss/oxide-linux-arm64-musl': 4.1.3 | ||||
|       '@tailwindcss/oxide-linux-x64-gnu': 4.1.3 | ||||
|       '@tailwindcss/oxide-linux-x64-musl': 4.1.3 | ||||
|       '@tailwindcss/oxide-win32-arm64-msvc': 4.1.3 | ||||
|       '@tailwindcss/oxide-win32-x64-msvc': 4.1.3 | ||||
|       '@tailwindcss/oxide-android-arm64': 4.1.4 | ||||
|       '@tailwindcss/oxide-darwin-arm64': 4.1.4 | ||||
|       '@tailwindcss/oxide-darwin-x64': 4.1.4 | ||||
|       '@tailwindcss/oxide-freebsd-x64': 4.1.4 | ||||
|       '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 | ||||
|       '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 | ||||
|       '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 | ||||
|       '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 | ||||
|       '@tailwindcss/oxide-linux-x64-musl': 4.1.4 | ||||
|       '@tailwindcss/oxide-wasm32-wasi': 4.1.4 | ||||
|       '@tailwindcss/oxide-win32-arm64-msvc': 4.1.4 | ||||
|       '@tailwindcss/oxide-win32-x64-msvc': 4.1.4 | ||||
|  | ||||
|   '@tailwindcss/postcss@4.1.3': | ||||
|   '@tailwindcss/postcss@4.1.4': | ||||
|     dependencies: | ||||
|       '@alloc/quick-lru': 5.2.0 | ||||
|       '@tailwindcss/node': 4.1.3 | ||||
|       '@tailwindcss/oxide': 4.1.3 | ||||
|       '@tailwindcss/node': 4.1.4 | ||||
|       '@tailwindcss/oxide': 4.1.4 | ||||
|       postcss: 8.5.3 | ||||
|       tailwindcss: 4.1.3 | ||||
|       tailwindcss: 4.1.4 | ||||
|  | ||||
|   '@tanstack/react-virtual@3.13.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': | ||||
|     dependencies: | ||||
| @@ -3360,7 +3367,7 @@ snapshots: | ||||
|  | ||||
|   '@types/d3-timer@3.0.2': {} | ||||
|  | ||||
|   '@types/node@22.14.0': | ||||
|   '@types/node@22.14.1': | ||||
|     dependencies: | ||||
|       undici-types: 6.21.0 | ||||
|  | ||||
| @@ -3417,18 +3424,6 @@ snapshots: | ||||
|  | ||||
|   clsx@2.1.1: {} | ||||
|  | ||||
|   cmdk@1.1.1(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): | ||||
|     dependencies: | ||||
|       '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.0)(react@19.1.0) | ||||
|       '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       '@radix-ui/react-id': 1.1.1(@types/react@19.1.0)(react@19.1.0) | ||||
|       '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       react: 19.1.0 | ||||
|       react-dom: 19.1.0(react@19.1.0) | ||||
|     transitivePeerDependencies: | ||||
|       - '@types/react' | ||||
|       - '@types/react-dom' | ||||
|  | ||||
|   color-convert@2.0.1: | ||||
|     dependencies: | ||||
|       color-name: 1.1.4 | ||||
| @@ -3735,7 +3730,7 @@ snapshots: | ||||
|  | ||||
|   mime@3.0.0: {} | ||||
|  | ||||
|   miniflare@4.20250416.0: | ||||
|   miniflare@4.20250417.0: | ||||
|     dependencies: | ||||
|       '@cspotcode/source-map-support': 0.8.1 | ||||
|       acorn: 8.14.0 | ||||
| @@ -3744,7 +3739,7 @@ snapshots: | ||||
|       glob-to-regexp: 0.4.1 | ||||
|       stoppable: 1.1.0 | ||||
|       undici: 5.29.0 | ||||
|       workerd: 1.20250416.0 | ||||
|       workerd: 1.20250417.0 | ||||
|       ws: 8.18.0 | ||||
|       youch: 3.3.4 | ||||
|       zod: 3.22.3 | ||||
| @@ -4025,11 +4020,11 @@ snapshots: | ||||
|  | ||||
|   tailwind-merge@3.2.0: {} | ||||
|  | ||||
|   tailwindcss-motion@1.1.0(tailwindcss@4.1.3): | ||||
|   tailwindcss-motion@1.1.0(tailwindcss@4.1.4): | ||||
|     dependencies: | ||||
|       tailwindcss: 4.1.3 | ||||
|       tailwindcss: 4.1.4 | ||||
|  | ||||
|   tailwindcss@4.1.3: {} | ||||
|   tailwindcss@4.1.4: {} | ||||
|  | ||||
|   tapable@2.2.1: {} | ||||
|  | ||||
| @@ -4100,24 +4095,24 @@ snapshots: | ||||
|  | ||||
|   web-vitals@4.2.4: {} | ||||
|  | ||||
|   workerd@1.20250416.0: | ||||
|   workerd@1.20250417.0: | ||||
|     optionalDependencies: | ||||
|       '@cloudflare/workerd-darwin-64': 1.20250416.0 | ||||
|       '@cloudflare/workerd-darwin-arm64': 1.20250416.0 | ||||
|       '@cloudflare/workerd-linux-64': 1.20250416.0 | ||||
|       '@cloudflare/workerd-linux-arm64': 1.20250416.0 | ||||
|       '@cloudflare/workerd-windows-64': 1.20250416.0 | ||||
|       '@cloudflare/workerd-darwin-64': 1.20250417.0 | ||||
|       '@cloudflare/workerd-darwin-arm64': 1.20250417.0 | ||||
|       '@cloudflare/workerd-linux-64': 1.20250417.0 | ||||
|       '@cloudflare/workerd-linux-arm64': 1.20250417.0 | ||||
|       '@cloudflare/workerd-windows-64': 1.20250417.0 | ||||
|  | ||||
|   wrangler@4.12.0: | ||||
|   wrangler@4.12.1: | ||||
|     dependencies: | ||||
|       '@cloudflare/kv-asset-handler': 0.4.0 | ||||
|       '@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250416.0) | ||||
|       '@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250417.0) | ||||
|       blake3-wasm: 2.1.5 | ||||
|       esbuild: 0.25.2 | ||||
|       miniflare: 4.20250416.0 | ||||
|       miniflare: 4.20250417.0 | ||||
|       path-to-regexp: 6.3.0 | ||||
|       unenv: 2.0.0-rc.15 | ||||
|       workerd: 1.20250416.0 | ||||
|       workerd: 1.20250417.0 | ||||
|     optionalDependencies: | ||||
|       fsevents: 2.3.3 | ||||
|       sharp: 0.33.5 | ||||
|   | ||||
| @@ -32,16 +32,16 @@ export default function ErrorPage({ | ||||
| 				</div> | ||||
| 				<h1 className="text-2xl font-bold">Something went wrong</h1> | ||||
| 				<p className="text-muted-foreground"> | ||||
| 					An unexpected error occurred while loading this page. We've been notified and are looking into it. | ||||
| 					Unable to load this page. We're looking into the issue. | ||||
| 				</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" /> | ||||
| 						Try again | ||||
| 						Retry | ||||
| 					</Button> | ||||
| 					<Button onClick={handleGoBack} className="cursor-pointer"> | ||||
| 						<ArrowLeft className="mr-2 h-4 w-4" /> | ||||
| 						Go back | ||||
| 						Back | ||||
| 					</Button> | ||||
| 				</div> | ||||
| 				{error.digest && <p className="text-xs text-muted-foreground mt-6">Error ID: {error.digest}</p>} | ||||
|   | ||||
| @@ -118,19 +118,6 @@ | ||||
| 			transform: rotate(-5deg) scale(0.9); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	--animate-shiny-text: shiny-text 8s infinite; | ||||
| 	@keyframes shiny-text { | ||||
| 		0%, | ||||
| 		90%, | ||||
| 		100% { | ||||
| 			background-position: calc(-100% - var(--shiny-width)) 0; | ||||
| 		} | ||||
| 		30%, | ||||
| 		60% { | ||||
| 			background-position: calc(100% + var(--shiny-width)) 0; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| :root { | ||||
| @@ -199,7 +186,7 @@ | ||||
| 	--secondary: oklch(0.31 0.03 266.71); | ||||
| 	--secondary-foreground: oklch(0.92 0 0); | ||||
| 	--muted: oklch(0.31 0.03 266.71); | ||||
| 	--muted-foreground: oklch(0.72 0 0); | ||||
| 	--muted-foreground: oklch(0.78 0 0); | ||||
| 	--accent: oklch(0.34 0.06 267.59); | ||||
| 	--accent-foreground: oklch(0.88 0.06 254.13); | ||||
| 	--destructive: oklch(0.64 0.21 25.33); | ||||
|   | ||||
| @@ -2,6 +2,12 @@ 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" | ||||
|  | ||||
| @@ -52,9 +58,9 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 				position: "relative", | ||||
| 				fontFamily: "Inter, system-ui, sans-serif", | ||||
| 				overflow: "hidden", | ||||
| 				backgroundColor: "white", | ||||
| 				backgroundColor: "#0f172a", // Dark background (slate-900) | ||||
| 				backgroundImage: | ||||
| 					"radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)", | ||||
| 					"radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)", | ||||
| 				backgroundSize: "100px 100px", | ||||
| 			}} | ||||
| 		> | ||||
| @@ -67,7 +73,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.1) 0%, rgba(59, 130, 246, 0.1) 100%)", | ||||
| 					background: "linear-gradient(135deg, rgba(56, 189, 248, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)", | ||||
| 					filter: "blur(80px)", | ||||
| 					zIndex: 2, | ||||
| 				}} | ||||
| @@ -80,7 +86,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.1) 0%, rgba(234, 88, 12, 0.1) 100%)", | ||||
| 					background: "linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(234, 88, 12, 0.15) 100%)", | ||||
| 					filter: "blur(100px)", | ||||
| 					zIndex: 2, | ||||
| 				}} | ||||
| @@ -109,8 +115,8 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 						width: 320, | ||||
| 						height: 320, | ||||
| 						borderRadius: 32, | ||||
| 						background: "white", | ||||
| 						boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)", | ||||
| 						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)", | ||||
| 						padding: 30, | ||||
| 						flexShrink: 0, | ||||
| 						position: "relative", | ||||
| @@ -121,7 +127,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 						style={{ | ||||
| 							position: "absolute", | ||||
| 							inset: 0, | ||||
| 							background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)", | ||||
| 							background: "linear-gradient(145deg, #1e293b 0%, #0f172a 100%)", | ||||
| 							zIndex: 0, | ||||
| 						}} | ||||
| 					/> | ||||
| @@ -134,7 +140,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.1))", | ||||
| 							filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.3))", | ||||
| 						}} | ||||
| 					/> | ||||
| 				</div> | ||||
| @@ -154,7 +160,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 							display: "flex", | ||||
| 							fontSize: 64, | ||||
| 							fontWeight: 800, | ||||
| 							color: "#0f172a", | ||||
| 							color: "#f8fafc", // Light text for dark background (slate-50) | ||||
| 							lineHeight: 1.1, | ||||
| 							letterSpacing: "-0.02em", | ||||
| 						}} | ||||
| @@ -167,14 +173,14 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 							display: "flex", | ||||
| 							fontSize: 32, | ||||
| 							fontWeight: 500, | ||||
| 							color: "#64748b", | ||||
| 							color: "#94a3b8", // Muted text (slate-400) | ||||
| 							lineHeight: 1.4, | ||||
| 							position: "relative", | ||||
| 							paddingLeft: 16, | ||||
| 							borderLeft: "4px solid #94a3b8", | ||||
| 							borderLeft: "4px solid #64748b", // slate-500 | ||||
| 						}} | ||||
| 					> | ||||
| 						Amongst {totalIcons} other high-quality dashboard icons | ||||
| 						{getIconDescription(formattedIconName, totalIcons)} | ||||
| 					</div> | ||||
|  | ||||
| 					<div | ||||
| @@ -191,14 +197,14 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 									display: "flex", | ||||
| 									alignItems: "center", | ||||
| 									justifyContent: "center", | ||||
| 									backgroundColor: "#f1f5f9", | ||||
| 									color: "#475569", | ||||
| 									border: "2px solid #e2e8f0", | ||||
| 									backgroundColor: "#334155", // slate-700 | ||||
| 									color: "#e2e8f0", // slate-200 | ||||
| 									border: "2px solid #475569", // slate-600 | ||||
| 									borderRadius: 12, | ||||
| 									padding: "8px 16px", | ||||
| 									fontSize: 18, | ||||
| 									fontWeight: 600, | ||||
| 									boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", | ||||
| 									boxShadow: "0 1px 2px rgba(0, 0, 0, 0.2)", | ||||
| 								}} | ||||
| 							> | ||||
| 								{format} | ||||
| @@ -219,8 +225,8 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 					display: "flex", | ||||
| 					alignItems: "center", | ||||
| 					justifyContent: "center", | ||||
| 					background: "#ffffff", | ||||
| 					borderTop: "2px solid rgba(0, 0, 0, 0.05)", | ||||
| 					background: "#1e293b", // slate-800 | ||||
| 					borderTop: "2px solid rgba(255, 255, 255, 0.1)", | ||||
| 					zIndex: 20, | ||||
| 				}} | ||||
| 			> | ||||
| @@ -229,7 +235,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 						display: "flex", | ||||
| 						fontSize: 24, | ||||
| 						fontWeight: 600, | ||||
| 						color: "#334155", | ||||
| 						color: "#e2e8f0", // slate-200 | ||||
| 						alignItems: "center", | ||||
| 						gap: 10, | ||||
| 					}} | ||||
| @@ -239,11 +245,11 @@ export default async function Image({ params }: { params: { icon: string } }) { | ||||
| 							width: 8, | ||||
| 							height: 8, | ||||
| 							borderRadius: "50%", | ||||
| 							backgroundColor: "#3b82f6", | ||||
| 							backgroundColor: "#3b82f6", // blue-500 | ||||
| 							marginRight: 4, | ||||
| 						}} | ||||
| 					/> | ||||
| 					dashboardicons.com | ||||
| 					{WEB_URL.replace("https://", "")} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div>, | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import { IconDetails } from "@/components/icon-details" | ||||
| import { BASE_URL, WEB_URL } from "@/constants" | ||||
| 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 { 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 | ||||
| @@ -40,43 +42,39 @@ 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: `${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.`, | ||||
| 		title, | ||||
| 		description, | ||||
| 		assets: [iconImageUrl], | ||||
| 		category: "icons", | ||||
| 		keywords: [ | ||||
| 			`${formattedIconName} icon`, | ||||
| 			"dashboard icon", | ||||
| 			"service icon", | ||||
| 			"application icon", | ||||
| 			"tool icon", | ||||
| 			"web dashboard", | ||||
| 			"app directory", | ||||
| 		], | ||||
| 		category: "Icons", | ||||
| 		keywords: ICON_DETAIL_KEYWORDS(formattedIconName), | ||||
| 		icons: { | ||||
| 			icon: iconImageUrl, | ||||
| 		}, | ||||
| 		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.`, | ||||
| 		abstract: description, | ||||
| 		robots: { | ||||
| 			index: true, | ||||
| 			follow: true, | ||||
| 		}, | ||||
| 		openGraph: { | ||||
| 			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.`, | ||||
| 			title: fullTitle, | ||||
| 			description, | ||||
| 			type: "article", | ||||
| 			url: pageUrl, | ||||
| 			authors: [authorName], | ||||
| 			publishedTime: updateDate.toISOString(), | ||||
| 			modifiedTime: updateDate.toISOString(), | ||||
| 			section: "Icons", | ||||
| 			tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"], | ||||
| 			tags: [formattedIconName, ...ICON_DETAIL_KEYWORDS(formattedIconName)], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			card: "summary_large_image", | ||||
| 			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.`, | ||||
| 			title: fullTitle, | ||||
| 			description, | ||||
| 			images: [iconImageUrl], | ||||
| 		}, | ||||
| 		alternates: { | ||||
| @@ -87,6 +85,9 @@ export async function generateMetadata({ params, searchParams }: Props, parent: | ||||
| 				webp: `${BASE_URL}/webp/${icon}.webp`, | ||||
| 			}, | ||||
| 		}, | ||||
| 		other: { | ||||
| 			"revisit-after": "7 days", | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -100,6 +101,26 @@ 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(" ") | ||||
|  | ||||
| 	return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} /> | ||||
| 	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} /> | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -1,87 +0,0 @@ | ||||
| "use client" | ||||
|  | ||||
| import { Input } from "@/components/ui/input" | ||||
| import { BASE_URL } from "@/constants" | ||||
| import type { IconSearchProps, IconWithName } from "@/types/icons" | ||||
| import { Search } from "lucide-react" | ||||
| import Image from "next/image" | ||||
| import Link from "next/link" | ||||
| import { useState } from "react" | ||||
|  | ||||
| export function IconSearch({ icons, initialQuery = "" }: IconSearchProps) { | ||||
| 	const [searchQuery, setSearchQuery] = useState(initialQuery) | ||||
| 	const [filteredIcons, setFilteredIcons] = useState<IconWithName[]>(() => { | ||||
| 		if (!initialQuery.trim()) return icons | ||||
|  | ||||
| 		const q = initialQuery.toLowerCase() | ||||
| 		return icons.filter(({ name, data }) => { | ||||
| 			if (name.toLowerCase().includes(q)) return true | ||||
| 			if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true | ||||
| 			if (data.categories.some((category) => category.toLowerCase().includes(q))) return true | ||||
|  | ||||
| 			return false | ||||
| 		}) | ||||
| 	}) | ||||
|  | ||||
| 	const handleSearch = (query: string) => { | ||||
| 		setSearchQuery(query) | ||||
|  | ||||
| 		if (!query.trim()) { | ||||
| 			setFilteredIcons(icons) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		const q = query.toLowerCase() | ||||
| 		const filtered = icons.filter(({ name, data }) => { | ||||
| 			if (name.toLowerCase().includes(q)) return true | ||||
| 			if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true | ||||
| 			if (data.categories.some((category) => category.toLowerCase().includes(q))) return true | ||||
|  | ||||
| 			return false | ||||
| 		}) | ||||
|  | ||||
| 		setFilteredIcons(filtered) | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<div className="relative w-full max-w-md"> | ||||
| 				<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground transition-all duration-300" /> | ||||
| 				<Input | ||||
| 					type="search" | ||||
| 					placeholder="Search icons by name, aliases, or categories..." | ||||
| 					className="w-full pl-8 transition-all duration-300 text-sm md:text-base" | ||||
| 					value={searchQuery} | ||||
| 					onChange={(e) => handleSearch(e.target.value)} | ||||
| 				/> | ||||
| 			</div> | ||||
|  | ||||
| 			{filteredIcons.length === 0 ? ( | ||||
| 				<div className="text-center py-12"> | ||||
| 					<h2 className="text-xl font-semibold">No icons found</h2> | ||||
| 					<p className="text-muted-foreground mt-2">Try a different search term.</p> | ||||
| 				</div> | ||||
| 			) : ( | ||||
| 				<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-8"> | ||||
| 					{filteredIcons.map(({ name, data }) => ( | ||||
| 						<Link | ||||
| 							key={name} | ||||
| 							href={`/icons/${name}`} | ||||
| 							className="group flex flex-col items-center p-4 rounded-lg border border-border hover:border-primary hover:bg-accent transition-colors" | ||||
| 						> | ||||
| 							<div className="relative h-16 w-16 mb-2"> | ||||
| 								<Image | ||||
| 									src={`${BASE_URL}/${data.base}/${name}.${data.base}`} | ||||
| 									alt={`${name} icon`} | ||||
| 									fill | ||||
| 									className="object-contain p-1 group-hover:scale-110 transition-transform" | ||||
| 								/> | ||||
| 							</div> | ||||
| 							<span className="text-sm text-center truncate w-full">{name.replace(/-/g, " ")}</span> | ||||
| 						</Link> | ||||
| 					))} | ||||
| 				</div> | ||||
| 			)} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
| @@ -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, Filter, Search, SortAsc, X } from "lucide-react" | ||||
| import { ArrowDownAZ, ArrowUpZA, Calendar, ChevronLeft, ChevronRight, Filter, Search, SortAsc, X } from "lucide-react" | ||||
| import { useTheme } from "next-themes" | ||||
| import Image from "next/image" | ||||
| import Link from "next/link" | ||||
| @@ -27,24 +27,82 @@ 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) | ||||
| @@ -138,7 +196,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 	}, [filterIcons, debouncedQuery, selectedCategories, sortOption]) | ||||
|  | ||||
| 	const updateResults = useCallback( | ||||
| 		(query: string, categories: string[], sort: SortOption) => { | ||||
| 		(query: string, categories: string[], sort: SortOption, page = 1) => { | ||||
| 			const params = new URLSearchParams() | ||||
| 			if (query) params.set("q", query) | ||||
|  | ||||
| @@ -152,6 +210,11 @@ 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 }) | ||||
| 		}, | ||||
| @@ -197,11 +260,20 @@ 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") | ||||
| 		updateResults("", [], "relevance") | ||||
| 		setCurrentPage(1) | ||||
| 		updateResults("", [], "relevance", 1) | ||||
| 	}, [updateResults]) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| @@ -228,11 +300,11 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 	const getSortLabel = (sort: SortOption) => { | ||||
| 		switch (sort) { | ||||
| 			case "relevance": | ||||
| 				return "Best match" | ||||
| 				return "Relevance" | ||||
| 			case "alphabetical-asc": | ||||
| 				return "A to Z" | ||||
| 				return "Name (A-Z)" | ||||
| 			case "alphabetical-desc": | ||||
| 				return "Z to A" | ||||
| 				return "Name (Z-A)" | ||||
| 			case "newest": | ||||
| 				return "Newest first" | ||||
| 			default: | ||||
| @@ -265,7 +337,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 					</div> | ||||
| 					<Input | ||||
| 						type="search" | ||||
| 						placeholder="Search icons by name, alias, or category..." | ||||
| 						placeholder="Search for icons..." | ||||
| 						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)} | ||||
| @@ -277,18 +349,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 "> | ||||
| 							<Button | ||||
| 								variant="outline" | ||||
| 								size="sm" | ||||
| 								className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm" | ||||
| 								aria-label="Filter icons" | ||||
| 							> | ||||
| 								<Filter className="h-4 w-4 mr-2" /> | ||||
| 								<span>Filter</span> | ||||
| 								{selectedCategories.length > 0 && ( | ||||
| 									<Badge variant="secondary" className="ml-2 px-1.5"> | ||||
| 										{selectedCategories.length} | ||||
| 									</Badge> | ||||
| 								)} | ||||
| 								<span>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span> | ||||
| 							</Button> | ||||
| 						</DropdownMenuTrigger> | ||||
| 						<DropdownMenuContent align="start" className="w-64 sm:w-56"> | ||||
| 							<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel> | ||||
| 							<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel> | ||||
| 							<DropdownMenuSeparator /> | ||||
|  | ||||
| 							<div className="max-h-[40vh] overflow-y-auto p-1"> | ||||
| @@ -314,7 +386,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 										}} | ||||
| 										className="cursor-pointer  focus: focus:bg-rose-50 dark:focus:bg-rose-950/20" | ||||
| 									> | ||||
| 										Clear all filters | ||||
| 										Clear categories | ||||
| 									</DropdownMenuItem> | ||||
| 								</> | ||||
| 							)} | ||||
| @@ -330,18 +402,18 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 							</Button> | ||||
| 						</DropdownMenuTrigger> | ||||
| 						<DropdownMenuContent align="start" className="w-56"> | ||||
| 							<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel> | ||||
| 							<DropdownMenuLabel className="font-semibold">Sort Icons</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" /> | ||||
| 									Best match | ||||
| 									Relevance | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer"> | ||||
| 									<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z | ||||
| 									<ArrowDownAZ className="h-4 w-4 mr-2" />Name (A-Z) | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer"> | ||||
| 									<ArrowUpZA className="h-4 w-4 mr-2" />Z to A | ||||
| 									<ArrowUpZA className="h-4 w-4 mr-2" />Name (Z-A) | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="newest" className="cursor-pointer"> | ||||
| 									<Calendar className="h-4 w-4 mr-2" /> | ||||
| @@ -353,9 +425,15 @@ 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"> | ||||
| 						<Button | ||||
| 							variant="outline" | ||||
| 							size="sm" | ||||
| 							onClick={clearFilters} | ||||
| 							className="flex-1 sm:flex-none cursor-pointer bg-background" | ||||
| 							aria-label="Reset all filters" | ||||
| 						> | ||||
| 							<X className="h-4 w-4 mr-2" /> | ||||
| 							<span>Clear all</span> | ||||
| 							<span>Reset</span> | ||||
| 						</Button> | ||||
| 					)} | ||||
| 				</div> | ||||
| @@ -363,7 +441,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">Filters:</span> | ||||
| 						<span className="text-sm text-muted-foreground">Selected:</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"> | ||||
| @@ -389,7 +467,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 							}} | ||||
| 							className="text-xs h-7 px-2 cursor-pointer" | ||||
| 						> | ||||
| 							Clear all | ||||
| 							Clear | ||||
| 						</Button> | ||||
| 					</div> | ||||
| 				)} | ||||
| @@ -400,27 +478,33 @@ 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">We don't have this one...yet!</h2> | ||||
| 						<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> | ||||
| 					</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> | ||||
| 			) : ( | ||||
| 				<> | ||||
| @@ -435,7 +519,14 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 					<IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} /> | ||||
| 					<IconsGrid | ||||
| 						filteredIcons={filteredIcons} | ||||
| 						matchedAliases={matchedAliases} | ||||
| 						currentPage={currentPage} | ||||
| 						iconsPerPage={iconsPerPage} | ||||
| 						onPageChange={handlePageChange} | ||||
| 						totalIcons={filteredIcons.length} | ||||
| 					/> | ||||
| 				</> | ||||
| 			)} | ||||
| 		</> | ||||
| @@ -445,15 +536,13 @@ 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"> | ||||
| 			<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer"> | ||||
| 		<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"> | ||||
| 				<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> | ||||
| 					<Image | ||||
| 						src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`} | ||||
| @@ -462,11 +551,9 @@ 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- 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-hover:text-rose-500 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> | ||||
| 	) | ||||
| @@ -475,17 +562,253 @@ 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 }: IconsGridProps) { | ||||
| 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()); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<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>} | ||||
| 			<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> | ||||
| 			)} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -1,49 +1,39 @@ | ||||
| import { BASE_URL } from "@/constants" | ||||
| 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 { 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: "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", | ||||
| 		], | ||||
| 		title, | ||||
| 		description, | ||||
| 		keywords: BROWSE_KEYWORDS, | ||||
| 		openGraph: { | ||||
| 			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.`, | ||||
| 			title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`, | ||||
| 			description, | ||||
| 			type: "website", | ||||
| 			url: `${BASE_URL}/icons`, | ||||
| 			images: [ | ||||
| 				{ | ||||
| 					url: "/og-image.png", | ||||
| 					width: 1200, | ||||
| 					height: 630, | ||||
| 					alt: "Browse Dashboard Icons Collection", | ||||
| 					type: "image/png", | ||||
| 				}, | ||||
| 			], | ||||
| 			url: `${WEB_URL}/icons`, | ||||
| 			images: [DEFAULT_OG_IMAGE], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			card: "summary_large_image", | ||||
| 			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"], | ||||
| 			title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`, | ||||
| 			description, | ||||
| 			images: [DEFAULT_OG_IMAGE.url], | ||||
| 		}, | ||||
| 		alternates: { | ||||
| 			canonical: `${BASE_URL}/icons`, | ||||
| 			canonical: `${WEB_URL}/icons`, | ||||
| 		}, | ||||
| 		other: { | ||||
| 			"revisit-after": "3 days", | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -51,20 +41,38 @@ export const dynamic = "force-static" | ||||
|  | ||||
| export default async function IconsPage() { | ||||
| 	const icons = await getIconsArray() | ||||
| 	return ( | ||||
| 		<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> | ||||
| 					</div> | ||||
|  | ||||
| 					<IconSearch icons={icons} /> | ||||
| 	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> | ||||
|  | ||||
| 						<IconSearch icons={icons} /> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -2,12 +2,13 @@ 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 { getDescription, websiteTitle } from "@/constants" | ||||
| 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 { ThemeProvider } from "./theme-provider" | ||||
|  | ||||
| const inter = Inter({ | ||||
| @@ -27,12 +28,13 @@ export const viewport: Viewport = { | ||||
|  | ||||
| export async function generateMetadata(): Promise<Metadata> { | ||||
| 	const { totalIcons } = await getTotalIcons() | ||||
| 	const description = getDescription(totalIcons) | ||||
|  | ||||
| 	return { | ||||
| 		metadataBase: new URL("https://dashboardicons.com"), | ||||
| 		metadataBase: new URL(WEB_URL), | ||||
| 		title: websiteTitle, | ||||
| 		description: getDescription(totalIcons), | ||||
| 		keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], | ||||
| 		description, | ||||
| 		keywords: DEFAULT_KEYWORDS, | ||||
| 		robots: { | ||||
| 			index: true, | ||||
| 			follow: true, | ||||
| @@ -42,33 +44,23 @@ export async function generateMetadata(): Promise<Metadata> { | ||||
| 			googleBot: "index, follow", | ||||
| 		}, | ||||
| 		openGraph: { | ||||
| 			siteName: "Dashboard Icons", | ||||
| 			siteName: SITE_NAME, | ||||
| 			type: "website", | ||||
| 			locale: "en_US", | ||||
| 			title: websiteTitle, | ||||
| 			description: getDescription(totalIcons), | ||||
| 			url: "https://dashboardicons.com", | ||||
| 			images: [ | ||||
| 				{ | ||||
| 					url: "/og-image.png", | ||||
| 					width: 1200, | ||||
| 					height: 630, | ||||
| 					alt: "Dashboard Icons", | ||||
| 					type: "image/png", | ||||
| 				}, | ||||
| 			], | ||||
| 			title: websiteFullTitle, | ||||
| 			description, | ||||
| 			url: WEB_URL, | ||||
| 			images: [DEFAULT_OG_IMAGE], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			card: "summary_large_image", | ||||
| 			site: "@homarr_app", | ||||
| 			creator: "@homarr_app", | ||||
| 			title: websiteTitle, | ||||
| 			description: getDescription(totalIcons), | ||||
| 			images: ["/og-image.png"], | ||||
| 			title: websiteFullTitle, | ||||
| 			description, | ||||
| 			images: [DEFAULT_OG_IMAGE.url], | ||||
| 		}, | ||||
| 		applicationName: "Dashboard Icons", | ||||
| 		applicationName: SITE_NAME, | ||||
| 		appleWebApp: { | ||||
| 			title: "Dashboard Icons", | ||||
| 			title: SITE_NAME, | ||||
| 			statusBarStyle: "default", | ||||
| 			capable: true, | ||||
| 		}, | ||||
| @@ -88,14 +80,29 @@ 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 function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { | ||||
| export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { | ||||
| 	const { totalIcons } = await getTotalIcons() | ||||
| 	const websiteSchema = getWebsiteSchema(totalIcons) | ||||
|  | ||||
| 	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">Icon not found</h1> | ||||
| 					<h1 className="text-2xl sm:text-3xl font-bold mt-6">Not found</h1> | ||||
| 					<p className="text-muted-foreground mt-3 max-w-md"> | ||||
| 						The icon you are looking for could not be found or there was an error loading it. | ||||
| 						This icon does not exist or could not be loaded. | ||||
| 					</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 all icons | ||||
| 							Back to 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">Can't find what you're looking for?</h2> | ||||
| 						<h2 className="text-xl font-semibold">Missing an icon?</h2> | ||||
| 						<p className="text-muted-foreground mt-2"> | ||||
| 							Contribute to our icon collection by suggesting a new icon or improving an existing one. | ||||
| 							Submit a new icon or suggest improvements to our collection. | ||||
| 						</p> | ||||
| 					</div> | ||||
|  | ||||
|   | ||||
| @@ -1,42 +1,37 @@ | ||||
| import { HeroSection } from "@/components/hero" | ||||
| import { RecentlyAddedIcons } from "@/components/recently-added-icons" | ||||
| import { BASE_URL, REPO_NAME, getDescription, websiteTitle } from "@/constants" | ||||
| 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 { 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: getDescription(totalIcons), | ||||
| 		keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], | ||||
| 		description, | ||||
| 		keywords: DEFAULT_KEYWORDS, | ||||
| 		robots: { | ||||
| 			index: true, | ||||
| 			follow: true, | ||||
| 		}, | ||||
| 		openGraph: { | ||||
| 			title: websiteTitle, | ||||
| 			description: getDescription(totalIcons), | ||||
| 			title: websiteFullTitle, | ||||
| 			description, | ||||
| 			type: "website", | ||||
| 			url: BASE_URL, | ||||
| 			images: [ | ||||
| 				{ | ||||
| 					url: "/og-image.png", | ||||
| 					width: 1200, | ||||
| 					height: 630, | ||||
| 					alt: "Dashboard Icons", | ||||
| 				}, | ||||
| 			], | ||||
| 			url: WEB_URL, | ||||
| 			images: [DEFAULT_OG_IMAGE], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			title: websiteTitle, | ||||
| 			description: getDescription(totalIcons), | ||||
| 			title: websiteFullTitle, | ||||
| 			description, | ||||
| 			card: "summary_large_image", | ||||
| 			images: ["/og-image.png"], | ||||
| 			images: [DEFAULT_OG_IMAGE.url], | ||||
| 		}, | ||||
| 		alternates: { | ||||
| 			canonical: BASE_URL, | ||||
| 			canonical: WEB_URL, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| @@ -54,9 +49,11 @@ 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> | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -1,138 +0,0 @@ | ||||
| "use client" | ||||
|  | ||||
| import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" | ||||
| import { useMediaQuery } from "@/hooks/use-media-query" | ||||
| import { fuzzySearch } from "@/lib/utils" | ||||
| import { Icon } from "@/types/icons" | ||||
| import { useRouter } from "next/navigation" | ||||
| import { useCallback, useEffect, useState } from "react" | ||||
|  | ||||
| interface CommandMenuProps { | ||||
| 	icons: { | ||||
| 		name: string | ||||
| 		data: { | ||||
| 			categories: string[] | ||||
| 			aliases: string[] | ||||
| 			[key: string]: unknown | ||||
| 		} | ||||
| 	}[] | ||||
| 	triggerButtonId?: string | ||||
| 	open?: boolean | ||||
| 	onOpenChange?: (open: boolean) => void | ||||
| } | ||||
|  | ||||
| export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalOnOpenChange }: CommandMenuProps) { | ||||
| 	const router = useRouter() | ||||
| 	const [internalOpen, setInternalOpen] = useState(false) | ||||
| 	const [query, setQuery] = useState("") | ||||
| 	const isDesktop = useMediaQuery("(min-width: 768px)") | ||||
|  | ||||
| 	// Use either external or internal state for controlling open state | ||||
| 	const isOpen = externalOpen !== undefined ? externalOpen : internalOpen | ||||
|  | ||||
| 	// Wrap setIsOpen in useCallback to fix dependency issue | ||||
| 	const setIsOpen = useCallback( | ||||
| 		(value: boolean) => { | ||||
| 			if (externalOnOpenChange) { | ||||
| 				externalOnOpenChange(value) | ||||
| 			} else { | ||||
| 				setInternalOpen(value) | ||||
| 			} | ||||
| 		}, | ||||
| 		[externalOnOpenChange], | ||||
| 	) | ||||
|  | ||||
| 	const filteredIcons = getFilteredIcons(icons, query) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const handleKeyDown = (e: KeyboardEvent) => { | ||||
| 			if ( | ||||
| 				(e.key === "k" && (e.metaKey || e.ctrlKey)) || | ||||
| 				(e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") | ||||
| 			) { | ||||
| 				e.preventDefault() | ||||
| 				setIsOpen(!isOpen) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		document.addEventListener("keydown", handleKeyDown) | ||||
| 		return () => document.removeEventListener("keydown", handleKeyDown) | ||||
| 	}, [isOpen, setIsOpen]) | ||||
|  | ||||
| 	function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) { | ||||
| 		if (!query) { | ||||
| 			// Return a limited number of icons when no query is provided | ||||
| 			return iconList.slice(0, 8) | ||||
| 		} | ||||
|  | ||||
| 		// Calculate scores for each icon | ||||
| 		const scoredIcons = iconList.map((icon) => { | ||||
| 			// Calculate scores for different fields | ||||
| 			const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches | ||||
|  | ||||
| 			// Get max score from aliases | ||||
| 			const aliasScore = | ||||
| 				icon.data.aliases && icon.data.aliases.length > 0 | ||||
| 					? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * 1.8 // Increased weight for aliases | ||||
| 					: 0 | ||||
|  | ||||
| 			// Get max score from categories | ||||
| 			const categoryScore = | ||||
| 				icon.data.categories && icon.data.categories.length > 0 | ||||
| 					? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) | ||||
| 					: 0 | ||||
|  | ||||
| 			// Use the highest score | ||||
| 			const score = Math.max(nameScore, aliasScore, categoryScore) | ||||
|  | ||||
| 			return { icon, score, matchedField: score === nameScore ? "name" : score === aliasScore ? "alias" : "category" } | ||||
| 		}) | ||||
|  | ||||
| 		// Filter icons with a minimum score and sort by highest score | ||||
| 		return scoredIcons | ||||
| 			.filter((item) => item.score > 0.3) // Higher threshold for more accurate results | ||||
| 			.sort((a, b) => b.score - a.score) | ||||
| 			.slice(0, 20) // Limit the number of results | ||||
| 			.map((item) => item.icon) | ||||
| 	} | ||||
|  | ||||
| 	const handleSelect = (name: string) => { | ||||
| 		setIsOpen(false) | ||||
| 		router.push(`/icons/${name}`) | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<CommandDialog open={isOpen} onOpenChange={setIsOpen}> | ||||
| 			<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} /> | ||||
| 			<CommandList> | ||||
| 				<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty> | ||||
| 				<CommandGroup heading="Icons"> | ||||
| 					{filteredIcons.map(({ name, data }) => { | ||||
| 						// Find matched alias for display if available | ||||
| 						const matchedAlias = | ||||
| 							query && data.aliases && data.aliases.length > 0 | ||||
| 								? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase())) | ||||
| 								: null | ||||
|  | ||||
| 						return ( | ||||
| 							<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer"> | ||||
| 								<div className="flex-shrink-0 h-5 w-5 relative"> | ||||
| 									<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center"> | ||||
| 										<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 								<span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span> | ||||
| 								{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>} | ||||
| 								{!matchedAlias && data.categories && data.categories.length > 0 && ( | ||||
| 									<span className="text-xs text-muted-foreground truncate max-w-[100px]"> | ||||
| 										{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} | ||||
| 									</span> | ||||
| 								)} | ||||
| 							</CommandItem> | ||||
| 						) | ||||
| 					})} | ||||
| 				</CommandGroup> | ||||
| 			</CommandList> | ||||
| 		</CommandDialog> | ||||
| 	) | ||||
| } | ||||
| @@ -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"> | ||||
| 							A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories. | ||||
| 							Collection of icons for applications, services, and tools - designed for dashboards and app directories. | ||||
| 						</p> | ||||
| 					</div> | ||||
|  | ||||
|   | ||||
| @@ -3,42 +3,14 @@ | ||||
| import { IconSubmissionForm } from "@/components/icon-submission-form" | ||||
| import { ThemeSwitcher } from "@/components/theme-switcher" | ||||
| import { REPO_PATH } from "@/constants" | ||||
| import { getIconsArray } from "@/lib/api" | ||||
| import type { IconWithName } from "@/types/icons" | ||||
| import { motion } from "framer-motion" | ||||
| import { Github, Search } from "lucide-react" | ||||
| import { Github } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" | ||||
|  | ||||
| export function Header() { | ||||
| 	const [iconsData, setIconsData] = useState<IconWithName[]>([]) | ||||
| 	const [isLoaded, setIsLoaded] = useState(false) | ||||
| 	const [commandMenuOpen, setCommandMenuOpen] = useState(false) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		async function loadIcons() { | ||||
| 			try { | ||||
| 				const icons = await getIconsArray() | ||||
| 				setIconsData(icons) | ||||
| 				setIsLoaded(true) | ||||
| 			} catch (error) { | ||||
| 				console.error("Failed to load icons:", error) | ||||
| 				setIsLoaded(true) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		loadIcons() | ||||
| 	}, []) | ||||
|  | ||||
| 	// Function to open the command menu | ||||
| 	const openCommandMenu = () => { | ||||
| 		setCommandMenuOpen(true) | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<motion.header | ||||
| 			className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50" | ||||
| @@ -56,30 +28,6 @@ export function Header() { | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div className="flex items-center gap-2 md:gap-4"> | ||||
| 					{/* Desktop search button */} | ||||
| 					<div className="hidden md:block"> | ||||
| 						<Button variant="outline" className="gap-2 cursor-pointer   transition-all duration-300" onClick={openCommandMenu}> | ||||
| 							<Search className="h-4 w-4 transition-all duration-300" /> | ||||
| 							<span>Find icons</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> | ||||
| 					</div> | ||||
|  | ||||
| 					{/* Mobile search button */} | ||||
| 					<div className="md:hidden"> | ||||
| 						<Button | ||||
| 							variant="ghost" | ||||
| 							size="icon" | ||||
| 							className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 " | ||||
| 							onClick={openCommandMenu} | ||||
| 						> | ||||
| 							<Search className="h-5 w-5 transition-all duration-300" /> | ||||
| 							<span className="sr-only">Find icons</span> | ||||
| 						</Button> | ||||
| 					</div> | ||||
|  | ||||
| 					<div className="hidden md:flex items-center gap-2 md:gap-4"> | ||||
| 						<IconSubmissionForm /> | ||||
| 						<TooltipProvider> | ||||
| @@ -106,9 +54,6 @@ export function Header() { | ||||
| 					<ThemeSwitcher /> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			{/* Single instance of CommandMenu */} | ||||
| 			{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />} | ||||
| 		</motion.header> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -205,13 +205,61 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars: | ||||
| 				/> | ||||
| 			</div> | ||||
|  | ||||
| 			<div className="relative z-10 container mx-auto px-4 md:px-6 mt-4 py-20"> | ||||
| 			<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8 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 | ||||
| 						<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" /> | ||||
| 						<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> | ||||
| 						<br /> | ||||
| 						<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" /> | ||||
| 						<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> | ||||
| 						<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText> | ||||
| 					</h1> | ||||
|  | ||||
| @@ -224,7 +272,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">Explore icons</InteractiveHoverButton> | ||||
| 								<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton> | ||||
| 							</Link> | ||||
| 							<GiveUsAStarButton stars={stars} /> | ||||
| 							<GiveUsMoneyButton /> | ||||
| @@ -449,12 +497,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"> | ||||
| 								Donate | ||||
| 								Support | ||||
| 							</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 expenses | ||||
| 								View transactions | ||||
| 								<ExternalLink className="h-3 w-3" /> | ||||
| 							</Button> | ||||
| 						</Link> | ||||
| @@ -478,7 +526,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro | ||||
| 				name="q" | ||||
| 				autoFocus | ||||
| 				type="search" | ||||
| 				placeholder={`Find any of ${totalIcons} icons by name or category...`} | ||||
| 				placeholder="Search for icons..." | ||||
| 				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,6 +207,7 @@ 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> | ||||
| @@ -223,6 +224,7 @@ 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> | ||||
| @@ -234,8 +236,18 @@ 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"> | ||||
| 									<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`} | ||||
| 										> | ||||
| 											<Github className="w-4 h-4" /> | ||||
| 										</Link> | ||||
| 									</Button> | ||||
| @@ -252,7 +264,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="container mx-auto pt-12 pb-14"> | ||||
| 		<div className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> | ||||
| 			<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> | ||||
| 				{/* Left Column: Icon Info and Author */} | ||||
| 				<div className="lg:col-span-1"> | ||||
| @@ -306,7 +318,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
|  | ||||
| 								{iconData.categories && iconData.categories.length > 0 && ( | ||||
| 									<div> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3> | ||||
| 										<div className="flex flex-wrap gap-2"> | ||||
| 											{iconData.categories.map((category) => ( | ||||
| 												<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer"> | ||||
| @@ -327,7 +339,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
|  | ||||
| 								{iconData.aliases && iconData.aliases.length > 0 && ( | ||||
| 									<div> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3> | ||||
| 										<div className="flex flex-wrap gap-2"> | ||||
| 											{iconData.aliases.map((alias) => ( | ||||
| 												<Badge | ||||
| @@ -344,19 +356,17 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 								)} | ||||
|  | ||||
| 								<div> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground">About this icon</h3> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground mb-2">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> | ||||
| 											Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user | ||||
| 											experience. | ||||
| 											Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")} logo. | ||||
| 										</p> | ||||
| 									</div> | ||||
| 								</div> | ||||
| @@ -412,7 +422,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">Base format</h3> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground mb-2">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> | ||||
| @@ -420,7 +430,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 								</div> | ||||
|  | ||||
| 								<div className=""> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground mb-2">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"> | ||||
| @@ -432,7 +442,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
|  | ||||
| 								{iconData.colors && ( | ||||
| 									<div className=""> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3> | ||||
| 										<h3 className="text-sm font-semibold text-muted-foreground mb-2">Color variants</h3> | ||||
| 										<div className="space-y-2"> | ||||
| 											{Object.entries(iconData.colors).map(([theme, variant]) => ( | ||||
| 												<div key={theme} className="flex items-center gap-2"> | ||||
| @@ -446,7 +456,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 								)} | ||||
|  | ||||
| 								<div className=""> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground">Source</h3> | ||||
| 									<h3 className="text-sm font-semibold text-muted-foreground mb-2">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 both light and dark versions for optimal theme compatibility.", | ||||
| 		name: "Add light/dark icon", | ||||
| 		description: "Submit a new icon with light and dark versions.", | ||||
| 		url: `${REPO_PATH}/issues/new?template=add_monochrome_icon.yml`, | ||||
| 	}, | ||||
| 	{ | ||||
| 		id: "add_normal_icon", | ||||
| 		name: "Add normal icon", | ||||
| 		description: "Submit a new icon that works well across both light and dark themes.", | ||||
| 		name: "Add standard icon", | ||||
| 		description: "Submit a new icon for both themes.", | ||||
| 		url: `${REPO_PATH}/issues/new?template=add_normal_icon.yml`, | ||||
| 	}, | ||||
| 	{ | ||||
| 		id: "update_monochrome_icon", | ||||
| 		name: "Update light & dark icon", | ||||
| 		description: "Improve an existing icon by updating both light and dark versions.", | ||||
| 		name: "Update light/dark icon", | ||||
| 		description: "Improve or update an existing light/dark icon.", | ||||
| 		url: `${REPO_PATH}/issues/new?template=update_monochrome_icon.yml`, | ||||
| 	}, | ||||
| 	{ | ||||
| 		id: "update_normal_icon", | ||||
| 		name: "Update normal icon", | ||||
| 		description: "Improve an existing icon that works across both light and dark themes.", | ||||
| 		name: "Update standard icon", | ||||
| 		description: "Improve or update an existing standard icon.", | ||||
| 		url: `${REPO_PATH}/issues/new?template=update_normal_icon.yml`, | ||||
| 	}, | ||||
| 	{ | ||||
| 		id: "blank_issue", | ||||
| 		name: "Something else", | ||||
| 		description: "Create a custom issue for other suggestions, bug reports, or improvements.", | ||||
| 		name: "Other request", | ||||
| 		description: "Submit another type of request.", | ||||
| 		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" /> Contribute new icon | ||||
| 					<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s) | ||||
| 				</Button> | ||||
| 			</DialogTrigger> | ||||
| 			<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background"> | ||||
| 				<DialogHeader> | ||||
| 					<DialogTitle>Contribute a new icon</DialogTitle> | ||||
| 					<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription> | ||||
| 					<DialogTitle>Submit an icon</DialogTitle> | ||||
| 					<DialogDescription>Select an option below to submit or update an icon.</DialogDescription> | ||||
| 				</DialogHeader> | ||||
| 				<div className="mt-4"> | ||||
| 					<IconSubmissionContent onClose={() => setOpen(false)} /> | ||||
|   | ||||
| @@ -1,33 +0,0 @@ | ||||
| import type { CSSProperties, ComponentPropsWithoutRef, FC } from "react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { | ||||
| 	shimmerWidth?: number | ||||
| } | ||||
|  | ||||
| export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({ children, className, shimmerWidth = 100, ...props }) => { | ||||
| 	return ( | ||||
| 		<span | ||||
| 			style={ | ||||
| 				{ | ||||
| 					"--shiny-width": `${shimmerWidth}px`, | ||||
| 				} as CSSProperties | ||||
| 			} | ||||
| 			className={cn( | ||||
| 				"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70", | ||||
|  | ||||
| 				// Shine effect | ||||
| 				"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]", | ||||
|  | ||||
| 				// Shine gradient | ||||
| 				"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent  dark:via-white/80", | ||||
|  | ||||
| 				className, | ||||
| 			)} | ||||
| 			{...props} | ||||
| 		> | ||||
| 			{children} | ||||
| 		</span> | ||||
| 	) | ||||
| } | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import { motion, useMotionTemplate, useMotionValue } from "motion/react" | ||||
| import type React from "react" | ||||
| import { useCallback, useEffect, useRef } from "react" | ||||
| import { useCallback, useEffect, useRef, useState } from "react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| @@ -28,6 +28,7 @@ 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) => { | ||||
| @@ -60,6 +61,14 @@ 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) | ||||
| @@ -69,22 +78,17 @@ export function MagicCard({ | ||||
| 			document.removeEventListener("mouseout", handleMouseOut) | ||||
| 			document.removeEventListener("mouseenter", handleMouseEnter) | ||||
| 		} | ||||
| 	}, [handleMouseEnter, handleMouseMove, handleMouseOut]) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		mouseX.set(-gradientSize) | ||||
| 		mouseY.set(-gradientSize) | ||||
| 	}, [gradientSize, mouseX, mouseY]) | ||||
| 	}, [isMounted, handleMouseEnter, handleMouseMove, handleMouseOut]) | ||||
|  | ||||
| 	return ( | ||||
| 		<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}> | ||||
| 		<div 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% | ||||
|           ) | ||||
|           `, | ||||
| @@ -100,7 +104,7 @@ export function MagicCard({ | ||||
| 					opacity: gradientOpacity, | ||||
| 				}} | ||||
| 			/> | ||||
| 			<div className="relative">{children}</div> | ||||
| 			<div ref={cardRef} 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-6 lg:px-8"> | ||||
| 			<div className="mx-auto px-4 sm: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 complete collection | ||||
| 						View all icons | ||||
| 						<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" /> | ||||
| 					</Link> | ||||
| 				</div> | ||||
|   | ||||
							
								
								
									
										33
									
								
								web/src/components/structured-data.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								web/src/components/structured-data.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| "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" /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| @@ -1,177 +0,0 @@ | ||||
| "use client" | ||||
|  | ||||
| import * as React from "react" | ||||
| import { Command as CommandPrimitive } from "cmdk" | ||||
| import { SearchIcon } from "lucide-react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
| } from "@/components/ui/dialog" | ||||
|  | ||||
| function Command({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive>) { | ||||
|   return ( | ||||
|     <CommandPrimitive | ||||
|       data-slot="command" | ||||
|       className={cn( | ||||
|         "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandDialog({ | ||||
|   title = "Command Palette", | ||||
|   description = "Search for a command to run...", | ||||
|   children, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Dialog> & { | ||||
|   title?: string | ||||
|   description?: string | ||||
| }) { | ||||
|   return ( | ||||
|     <Dialog {...props}> | ||||
|       <DialogHeader className="sr-only"> | ||||
|         <DialogTitle>{title}</DialogTitle> | ||||
|         <DialogDescription>{description}</DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <DialogContent className="overflow-hidden p-0"> | ||||
|         <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | ||||
|           {children} | ||||
|         </Command> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandInput({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Input>) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="command-input-wrapper" | ||||
|       className="flex h-9 items-center gap-2 border-b px-3" | ||||
|     > | ||||
|       <SearchIcon className="size-4 shrink-0 opacity-50" /> | ||||
|       <CommandPrimitive.Input | ||||
|         data-slot="command-input" | ||||
|         className={cn( | ||||
|           "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandList({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.List>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.List | ||||
|       data-slot="command-list" | ||||
|       className={cn( | ||||
|         "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandEmpty({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Empty>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Empty | ||||
|       data-slot="command-empty" | ||||
|       className="py-6 text-center text-sm" | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandGroup({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Group>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Group | ||||
|       data-slot="command-group" | ||||
|       className={cn( | ||||
|         "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandSeparator({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Separator>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Separator | ||||
|       data-slot="command-separator" | ||||
|       className={cn("bg-border -mx-1 h-px", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandItem({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Item>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Item | ||||
|       data-slot="command-item" | ||||
|       className={cn( | ||||
|         "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandShortcut({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"span">) { | ||||
|   return ( | ||||
|     <span | ||||
|       data-slot="command-shortcut" | ||||
|       className={cn( | ||||
|         "text-muted-foreground ml-auto text-xs tracking-widest", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Command, | ||||
|   CommandDialog, | ||||
|   CommandInput, | ||||
|   CommandList, | ||||
|   CommandEmpty, | ||||
|   CommandGroup, | ||||
|   CommandItem, | ||||
|   CommandShortcut, | ||||
|   CommandSeparator, | ||||
| } | ||||
| @@ -4,7 +4,119 @@ 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" | ||||
|  | ||||
| export const getDescription = (totalIcons: number) => | ||||
| 	`A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.` | ||||
| // 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 websiteTitle = "Free Dashboard Icons - Download High-Quality UI & App Icons" | ||||
| 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}.` | ||||
|  | ||||
| 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" | ||||
| } | ||||
|   | ||||
| @@ -1,25 +0,0 @@ | ||||
| "use client" | ||||
|  | ||||
| import { useEffect, useState } from "react" | ||||
|  | ||||
| export function useMediaQuery(query: string): boolean { | ||||
| 	const [matches, setMatches] = useState(false) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const media = window.matchMedia(query) | ||||
|  | ||||
| 		// Initial check | ||||
| 		if (media.matches !== matches) { | ||||
| 			setMatches(media.matches) | ||||
| 		} | ||||
|  | ||||
| 		// Setup listener for changes | ||||
| 		const listener = () => setMatches(media.matches) | ||||
| 		media.addEventListener("change", listener) | ||||
|  | ||||
| 		// Cleanup | ||||
| 		return () => media.removeEventListener("change", listener) | ||||
| 	}, [query, matches]) | ||||
|  | ||||
| 	return matches | ||||
| } | ||||
		Reference in New Issue
	
	Block a user