mirror of
				https://github.com/walkxcode/dashboard-icons.git
				synced 2025-10-27 13:39:03 +08:00 
			
		
		
		
	Compare commits
	
		
			16 Commits
		
	
	
		
			main
			...
			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" | 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. | description: Submit a new icon with light and dark versions. | ||||||
| title: "feat(icons): add [NAME]" | title: "feat(icons): add [NAME]" | ||||||
| labels: ["monochrome-icon"] | labels: ["monochrome-icon"] | ||||||
| body: | 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" | name: "Add standard icon" | ||||||
| description: Use this template to add a new icon to the project. Normal icons work for both light and dark themes. | description: Submit a new icon for both light and dark themes. | ||||||
| title: "feat(icons): add [NAME]" | title: "feat(icons): add [NAME]" | ||||||
| labels: ["normal-icon"] | labels: ["normal-icon"] | ||||||
| body: | body: | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| name: "Update light & dark icon" | name: "Update light/dark icon" | ||||||
| description: Use this template to update an existing icon. Monochrome icons need both light and dark versions. | description: Improve or update an existing light/dark icon. | ||||||
| title: "feat(icons): update [NAME]" | title: "feat(icons): update [NAME]" | ||||||
| labels: ["monochrome-icon"] | labels: ["monochrome-icon"] | ||||||
| body: | body: | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| name: "Update normal icon" | name: "Update standard icon" | ||||||
| description: Use this template to update an existing icon. Normal icons work for both light and dark themes. | description: Improve or update an existing standard icon. | ||||||
| title: "feat(icons): update [NAME]" | title: "feat(icons): update [NAME]" | ||||||
| labels: ["normal-icon"] | labels: ["normal-icon"] | ||||||
| body: | body: | ||||||
|   | |||||||
| @@ -42,7 +42,6 @@ | |||||||
| 		"canvas-confetti": "^1.9.3", | 		"canvas-confetti": "^1.9.3", | ||||||
| 		"class-variance-authority": "^0.7.1", | 		"class-variance-authority": "^0.7.1", | ||||||
| 		"clsx": "^2.1.1", | 		"clsx": "^2.1.1", | ||||||
| 		"cmdk": "^1.1.1", |  | ||||||
| 		"date-fns": "^4.1.0", | 		"date-fns": "^4.1.0", | ||||||
| 		"embla-carousel-react": "^8.6.0", | 		"embla-carousel-react": "^8.6.0", | ||||||
| 		"framer-motion": "^12.7.3", | 		"framer-motion": "^12.7.3", | ||||||
|   | |||||||
							
								
								
									
										249
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										249
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -101,9 +101,6 @@ importers: | |||||||
|       clsx: |       clsx: | ||||||
|         specifier: ^2.1.1 |         specifier: ^2.1.1 | ||||||
|         version: 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: |       date-fns: | ||||||
|         specifier: ^4.1.0 |         specifier: ^4.1.0 | ||||||
|         version: 4.1.0 |         version: 4.1.0 | ||||||
| @@ -160,7 +157,7 @@ importers: | |||||||
|         version: 3.2.0 |         version: 3.2.0 | ||||||
|       tailwindcss-motion: |       tailwindcss-motion: | ||||||
|         specifier: ^1.1.0 |         specifier: ^1.1.0 | ||||||
|         version: 1.1.0(tailwindcss@4.1.3) |         version: 1.1.0(tailwindcss@4.1.4) | ||||||
|       tw-animate-css: |       tw-animate-css: | ||||||
|         specifier: ^1.2.5 |         specifier: ^1.2.5 | ||||||
|         version: 1.2.5 |         version: 1.2.5 | ||||||
| @@ -176,13 +173,13 @@ importers: | |||||||
|         version: 1.9.4 |         version: 1.9.4 | ||||||
|       '@tailwindcss/postcss': |       '@tailwindcss/postcss': | ||||||
|         specifier: ^4.1.3 |         specifier: ^4.1.3 | ||||||
|         version: 4.1.3 |         version: 4.1.4 | ||||||
|       '@types/canvas-confetti': |       '@types/canvas-confetti': | ||||||
|         specifier: ^1.9.0 |         specifier: ^1.9.0 | ||||||
|         version: 1.9.0 |         version: 1.9.0 | ||||||
|       '@types/node': |       '@types/node': | ||||||
|         specifier: ^22.14.0 |         specifier: ^22.14.0 | ||||||
|         version: 22.14.0 |         version: 22.14.1 | ||||||
|       '@types/react': |       '@types/react': | ||||||
|         specifier: ^19.1.0 |         specifier: ^19.1.0 | ||||||
|         version: 19.1.0 |         version: 19.1.0 | ||||||
| @@ -191,13 +188,13 @@ importers: | |||||||
|         version: 19.1.2(@types/react@19.1.0) |         version: 19.1.2(@types/react@19.1.0) | ||||||
|       tailwindcss: |       tailwindcss: | ||||||
|         specifier: ^4.1.3 |         specifier: ^4.1.3 | ||||||
|         version: 4.1.3 |         version: 4.1.4 | ||||||
|       typescript: |       typescript: | ||||||
|         specifier: ^5.8.3 |         specifier: ^5.8.3 | ||||||
|         version: 5.8.3 |         version: 5.8.3 | ||||||
|       wrangler: |       wrangler: | ||||||
|         specifier: ^4.12.0 |         specifier: ^4.12.0 | ||||||
|         version: 4.12.0 |         version: 4.12.1 | ||||||
|  |  | ||||||
| packages: | packages: | ||||||
|  |  | ||||||
| @@ -275,32 +272,32 @@ packages: | |||||||
|       workerd: |       workerd: | ||||||
|         optional: true |         optional: true | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-darwin-64@1.20250416.0': |   '@cloudflare/workerd-darwin-64@1.20250417.0': | ||||||
|     resolution: {integrity: sha512-aZgF8Swp9eVYxJPWOoZbAgAaYjWuYqGmEA+QJ2ecRGDBqm87rT4GEw7/mmLpxrpllny3VfEEhkk9iYCGv8nlFw==} |     resolution: {integrity: sha512-4Adfl92aKepjxb8e6af2d+xpD2sBOADgHqvkyXsFmoLb80weMEDDRGJi1p1m5q1M78/oVnGcpdmuRCAathanRg==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [darwin] |     os: [darwin] | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-darwin-arm64@1.20250416.0': |   '@cloudflare/workerd-darwin-arm64@1.20250417.0': | ||||||
|     resolution: {integrity: sha512-FhswG1QYRfaTZ4FAlUkfVWaoM2lrlqumiBTrhbo9czMJdGR/oBXS4SGynuI6zyhApHeBf3/fZpA/SBAe4cXdgg==} |     resolution: {integrity: sha512-dSlk18F4i3T1OTzFBxx3pKpXRMP6w2xZ26+oIV32BFWrCi/HxGzUd6gVA0q37oLGqITRt8xU693J4Gl1CwC/Ag==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [darwin] |     os: [darwin] | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-linux-64@1.20250416.0': |   '@cloudflare/workerd-linux-64@1.20250417.0': | ||||||
|     resolution: {integrity: sha512-G+nXEAJ/9y+A857XShwxKeRdfxok6UcjiQe6G+wQeCn/Ofkp/EWydacKdyeVU6QIm1oHS78DwJ7AzbCYywf9aw==} |     resolution: {integrity: sha512-27MVzOa/lENcqewC2L9EcqstXW843UhjBMcwV1umDfsjwLyZOEv6Gtm/6j5r0L0gASvkRTam3fAmtPk/gt48TA==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-linux-arm64@1.20250416.0': |   '@cloudflare/workerd-linux-arm64@1.20250417.0': | ||||||
|     resolution: {integrity: sha512-U6oVW0d9w1fpnDYNrjPJ9SFkDlGJWJWbXHlTBObXl6vccP16WewvuxyHkKqyUhUc8hyBaph7sxeKzKmuCFQ4SA==} |     resolution: {integrity: sha512-34qBk0htAXmUneOTQxW6/g6pjNVR91r0vJzz2FID84cAIOYVl4hZLijkjmVl+MMDU6boXUs+yDwhItdg06YvAg==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-windows-64@1.20250416.0': |   '@cloudflare/workerd-windows-64@1.20250417.0': | ||||||
|     resolution: {integrity: sha512-YAjjTzL1z9YYeN4sqYfj1dtQXd2Bblj+B+hl4Rz2aOhblpZEZAdhapZlOCRvLLkOJshKJUnRD3mDlytAdgwybQ==} |     resolution: {integrity: sha512-PDwATFioff+geVHfgTzSWsxgwjgotrdXStb0EL0lMyMT5zNmHArAnOx83CbDtud63Uv9rVX1BAfPP4tyD1O+5A==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [win32] |     os: [win32] | ||||||
| @@ -1365,81 +1362,93 @@ packages: | |||||||
|   '@swc/helpers@0.5.15': |   '@swc/helpers@0.5.15': | ||||||
|     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} |     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} | ||||||
|  |  | ||||||
|   '@tailwindcss/node@4.1.3': |   '@tailwindcss/node@4.1.4': | ||||||
|     resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==} |     resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-android-arm64@4.1.3': |   '@tailwindcss/oxide-android-arm64@4.1.4': | ||||||
|     resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==} |     resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [android] |     os: [android] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.3': |   '@tailwindcss/oxide-darwin-arm64@4.1.4': | ||||||
|     resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==} |     resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [darwin] |     os: [darwin] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-darwin-x64@4.1.3': |   '@tailwindcss/oxide-darwin-x64@4.1.4': | ||||||
|     resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==} |     resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [darwin] |     os: [darwin] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.3': |   '@tailwindcss/oxide-freebsd-x64@4.1.4': | ||||||
|     resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==} |     resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [freebsd] |     os: [freebsd] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3': |   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': | ||||||
|     resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==} |     resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm] |     cpu: [arm] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.3': |   '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': | ||||||
|     resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==} |     resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.3': |   '@tailwindcss/oxide-linux-arm64-musl@4.1.4': | ||||||
|     resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==} |     resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.3': |   '@tailwindcss/oxide-linux-x64-gnu@4.1.4': | ||||||
|     resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==} |     resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.3': |   '@tailwindcss/oxide-linux-x64-musl@4.1.4': | ||||||
|     resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==} |     resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': |   '@tailwindcss/oxide-wasm32-wasi@4.1.4': | ||||||
|     resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==} |     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'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [win32] |     os: [win32] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.3': |   '@tailwindcss/oxide-win32-x64-msvc@4.1.4': | ||||||
|     resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==} |     resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [win32] |     os: [win32] | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide@4.1.3': |   '@tailwindcss/oxide@4.1.4': | ||||||
|     resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==} |     resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} | ||||||
|     engines: {node: '>= 10'} |     engines: {node: '>= 10'} | ||||||
|  |  | ||||||
|   '@tailwindcss/postcss@4.1.3': |   '@tailwindcss/postcss@4.1.4': | ||||||
|     resolution: {integrity: sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg==} |     resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==} | ||||||
|  |  | ||||||
|   '@tanstack/react-virtual@3.13.6': |   '@tanstack/react-virtual@3.13.6': | ||||||
|     resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} |     resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} | ||||||
| @@ -1480,8 +1489,8 @@ packages: | |||||||
|   '@types/d3-timer@3.0.2': |   '@types/d3-timer@3.0.2': | ||||||
|     resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} |     resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} | ||||||
|  |  | ||||||
|   '@types/node@22.14.0': |   '@types/node@22.14.1': | ||||||
|     resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} |     resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} | ||||||
|  |  | ||||||
|   '@types/react-dom@19.1.2': |   '@types/react-dom@19.1.2': | ||||||
|     resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} |     resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} | ||||||
| @@ -1540,12 +1549,6 @@ packages: | |||||||
|     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} |     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | ||||||
|     engines: {node: '>=6'} |     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: |   color-convert@2.0.1: | ||||||
|     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} |     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | ||||||
|     engines: {node: '>=7.0.0'} |     engines: {node: '>=7.0.0'} | ||||||
| @@ -1888,8 +1891,8 @@ packages: | |||||||
|     engines: {node: '>=10.0.0'} |     engines: {node: '>=10.0.0'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|  |  | ||||||
|   miniflare@4.20250416.0: |   miniflare@4.20250417.0: | ||||||
|     resolution: {integrity: sha512-261PhPgD9zs5/BTdbWqwiaXtWxb+Av5zKCwTU+HXrA5E4tf3qnULwh3u6SVUOAEArEroFuKJzawsQ9COtNBurQ==} |     resolution: {integrity: sha512-bROKLQKr4CoS93tnGuw5e08VaNwM3VowTL3Z2Cps1HzY6a4Bq8uNtggQ7WogriMq77jcHn6kbz64bvWyF//Jkw==} | ||||||
|     engines: {node: '>=18.0.0'} |     engines: {node: '>=18.0.0'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|  |  | ||||||
| @@ -2153,8 +2156,8 @@ packages: | |||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       tailwindcss: '>=3.0.0 || insiders' |       tailwindcss: '>=3.0.0 || insiders' | ||||||
|  |  | ||||||
|   tailwindcss@4.1.3: |   tailwindcss@4.1.4: | ||||||
|     resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==} |     resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} | ||||||
|  |  | ||||||
|   tapable@2.2.1: |   tapable@2.2.1: | ||||||
|     resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} |     resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} | ||||||
| @@ -2219,17 +2222,17 @@ packages: | |||||||
|   web-vitals@4.2.4: |   web-vitals@4.2.4: | ||||||
|     resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} |     resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} | ||||||
|  |  | ||||||
|   workerd@1.20250416.0: |   workerd@1.20250417.0: | ||||||
|     resolution: {integrity: sha512-Yrx/bZAKbmSvomdTAzzIpOHwpYhs0ldr2wqed22UEhQ0mIplAHY4xmY+SjAJhP/TydZrciOVzBxwM1+4T40KNA==} |     resolution: {integrity: sha512-naz6oJiVODd3/Lkp9l3vtc56HKOOvx+AWDvEsTa5eSfi5SI9V0HYpLYSPblAwrfazbQ4ff1Vl3jkTl/5JxqCAA==} | ||||||
|     engines: {node: '>=16'} |     engines: {node: '>=16'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|  |  | ||||||
|   wrangler@4.12.0: |   wrangler@4.12.1: | ||||||
|     resolution: {integrity: sha512-4rfAXOi5KqM3ECvOrZJ97k3zEqxVwtdt4bijd8jcRBZ6iJYvEtjgjVi4TsfkVa/eXGhpfHTUnKu2uk8UHa8M2w==} |     resolution: {integrity: sha512-jYrz8y2ffhsRqvQLO2dXFi9HLvPUJk3jn7U71GWfBBCHm0I6r2ik7Vs9ajpRcTGlbNw1RY0uIHVJBVR/7bEN5A==} | ||||||
|     engines: {node: '>=18.0.0'} |     engines: {node: '>=18.0.0'} | ||||||
|     hasBin: true |     hasBin: true | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       '@cloudflare/workers-types': ^4.20250415.0 |       '@cloudflare/workers-types': ^4.20250417.0 | ||||||
|     peerDependenciesMeta: |     peerDependenciesMeta: | ||||||
|       '@cloudflare/workers-types': |       '@cloudflare/workers-types': | ||||||
|         optional: true |         optional: true | ||||||
| @@ -2302,25 +2305,25 @@ snapshots: | |||||||
|     dependencies: |     dependencies: | ||||||
|       mime: 3.0.0 |       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: |     dependencies: | ||||||
|       unenv: 2.0.0-rc.15 |       unenv: 2.0.0-rc.15 | ||||||
|     optionalDependencies: |     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 |     optional: true | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-darwin-arm64@1.20250416.0': |   '@cloudflare/workerd-darwin-arm64@1.20250417.0': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-linux-64@1.20250416.0': |   '@cloudflare/workerd-linux-64@1.20250417.0': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-linux-arm64@1.20250416.0': |   '@cloudflare/workerd-linux-arm64@1.20250417.0': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@cloudflare/workerd-windows-64@1.20250416.0': |   '@cloudflare/workerd-windows-64@1.20250417.0': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@cspotcode/source-map-support@0.8.1': |   '@cspotcode/source-map-support@0.8.1': | ||||||
| @@ -3264,67 +3267,71 @@ snapshots: | |||||||
|     dependencies: |     dependencies: | ||||||
|       tslib: 2.8.1 |       tslib: 2.8.1 | ||||||
|  |  | ||||||
|   '@tailwindcss/node@4.1.3': |   '@tailwindcss/node@4.1.4': | ||||||
|     dependencies: |     dependencies: | ||||||
|       enhanced-resolve: 5.18.1 |       enhanced-resolve: 5.18.1 | ||||||
|       jiti: 2.4.2 |       jiti: 2.4.2 | ||||||
|       lightningcss: 1.29.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 |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-darwin-arm64@4.1.3': |   '@tailwindcss/oxide-darwin-arm64@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-darwin-x64@4.1.3': |   '@tailwindcss/oxide-darwin-x64@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-freebsd-x64@4.1.3': |   '@tailwindcss/oxide-freebsd-x64@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3': |   '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm64-gnu@4.1.3': |   '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-arm64-musl@4.1.3': |   '@tailwindcss/oxide-linux-arm64-musl@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-x64-gnu@4.1.3': |   '@tailwindcss/oxide-linux-x64-gnu@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-linux-x64-musl@4.1.3': |   '@tailwindcss/oxide-linux-x64-musl@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-win32-arm64-msvc@4.1.3': |   '@tailwindcss/oxide-wasm32-wasi@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide-win32-x64-msvc@4.1.3': |   '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': | ||||||
|     optional: true |     optional: true | ||||||
|  |  | ||||||
|   '@tailwindcss/oxide@4.1.3': |   '@tailwindcss/oxide-win32-x64-msvc@4.1.4': | ||||||
|  |     optional: true | ||||||
|  |  | ||||||
|  |   '@tailwindcss/oxide@4.1.4': | ||||||
|     optionalDependencies: |     optionalDependencies: | ||||||
|       '@tailwindcss/oxide-android-arm64': 4.1.3 |       '@tailwindcss/oxide-android-arm64': 4.1.4 | ||||||
|       '@tailwindcss/oxide-darwin-arm64': 4.1.3 |       '@tailwindcss/oxide-darwin-arm64': 4.1.4 | ||||||
|       '@tailwindcss/oxide-darwin-x64': 4.1.3 |       '@tailwindcss/oxide-darwin-x64': 4.1.4 | ||||||
|       '@tailwindcss/oxide-freebsd-x64': 4.1.3 |       '@tailwindcss/oxide-freebsd-x64': 4.1.4 | ||||||
|       '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3 |       '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 | ||||||
|       '@tailwindcss/oxide-linux-arm64-gnu': 4.1.3 |       '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 | ||||||
|       '@tailwindcss/oxide-linux-arm64-musl': 4.1.3 |       '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 | ||||||
|       '@tailwindcss/oxide-linux-x64-gnu': 4.1.3 |       '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 | ||||||
|       '@tailwindcss/oxide-linux-x64-musl': 4.1.3 |       '@tailwindcss/oxide-linux-x64-musl': 4.1.4 | ||||||
|       '@tailwindcss/oxide-win32-arm64-msvc': 4.1.3 |       '@tailwindcss/oxide-wasm32-wasi': 4.1.4 | ||||||
|       '@tailwindcss/oxide-win32-x64-msvc': 4.1.3 |       '@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: |     dependencies: | ||||||
|       '@alloc/quick-lru': 5.2.0 |       '@alloc/quick-lru': 5.2.0 | ||||||
|       '@tailwindcss/node': 4.1.3 |       '@tailwindcss/node': 4.1.4 | ||||||
|       '@tailwindcss/oxide': 4.1.3 |       '@tailwindcss/oxide': 4.1.4 | ||||||
|       postcss: 8.5.3 |       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)': |   '@tanstack/react-virtual@3.13.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -3360,7 +3367,7 @@ snapshots: | |||||||
|  |  | ||||||
|   '@types/d3-timer@3.0.2': {} |   '@types/d3-timer@3.0.2': {} | ||||||
|  |  | ||||||
|   '@types/node@22.14.0': |   '@types/node@22.14.1': | ||||||
|     dependencies: |     dependencies: | ||||||
|       undici-types: 6.21.0 |       undici-types: 6.21.0 | ||||||
|  |  | ||||||
| @@ -3417,18 +3424,6 @@ snapshots: | |||||||
|  |  | ||||||
|   clsx@2.1.1: {} |   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: |   color-convert@2.0.1: | ||||||
|     dependencies: |     dependencies: | ||||||
|       color-name: 1.1.4 |       color-name: 1.1.4 | ||||||
| @@ -3735,7 +3730,7 @@ snapshots: | |||||||
|  |  | ||||||
|   mime@3.0.0: {} |   mime@3.0.0: {} | ||||||
|  |  | ||||||
|   miniflare@4.20250416.0: |   miniflare@4.20250417.0: | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@cspotcode/source-map-support': 0.8.1 |       '@cspotcode/source-map-support': 0.8.1 | ||||||
|       acorn: 8.14.0 |       acorn: 8.14.0 | ||||||
| @@ -3744,7 +3739,7 @@ snapshots: | |||||||
|       glob-to-regexp: 0.4.1 |       glob-to-regexp: 0.4.1 | ||||||
|       stoppable: 1.1.0 |       stoppable: 1.1.0 | ||||||
|       undici: 5.29.0 |       undici: 5.29.0 | ||||||
|       workerd: 1.20250416.0 |       workerd: 1.20250417.0 | ||||||
|       ws: 8.18.0 |       ws: 8.18.0 | ||||||
|       youch: 3.3.4 |       youch: 3.3.4 | ||||||
|       zod: 3.22.3 |       zod: 3.22.3 | ||||||
| @@ -4025,11 +4020,11 @@ snapshots: | |||||||
|  |  | ||||||
|   tailwind-merge@3.2.0: {} |   tailwind-merge@3.2.0: {} | ||||||
|  |  | ||||||
|   tailwindcss-motion@1.1.0(tailwindcss@4.1.3): |   tailwindcss-motion@1.1.0(tailwindcss@4.1.4): | ||||||
|     dependencies: |     dependencies: | ||||||
|       tailwindcss: 4.1.3 |       tailwindcss: 4.1.4 | ||||||
|  |  | ||||||
|   tailwindcss@4.1.3: {} |   tailwindcss@4.1.4: {} | ||||||
|  |  | ||||||
|   tapable@2.2.1: {} |   tapable@2.2.1: {} | ||||||
|  |  | ||||||
| @@ -4100,24 +4095,24 @@ snapshots: | |||||||
|  |  | ||||||
|   web-vitals@4.2.4: {} |   web-vitals@4.2.4: {} | ||||||
|  |  | ||||||
|   workerd@1.20250416.0: |   workerd@1.20250417.0: | ||||||
|     optionalDependencies: |     optionalDependencies: | ||||||
|       '@cloudflare/workerd-darwin-64': 1.20250416.0 |       '@cloudflare/workerd-darwin-64': 1.20250417.0 | ||||||
|       '@cloudflare/workerd-darwin-arm64': 1.20250416.0 |       '@cloudflare/workerd-darwin-arm64': 1.20250417.0 | ||||||
|       '@cloudflare/workerd-linux-64': 1.20250416.0 |       '@cloudflare/workerd-linux-64': 1.20250417.0 | ||||||
|       '@cloudflare/workerd-linux-arm64': 1.20250416.0 |       '@cloudflare/workerd-linux-arm64': 1.20250417.0 | ||||||
|       '@cloudflare/workerd-windows-64': 1.20250416.0 |       '@cloudflare/workerd-windows-64': 1.20250417.0 | ||||||
|  |  | ||||||
|   wrangler@4.12.0: |   wrangler@4.12.1: | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@cloudflare/kv-asset-handler': 0.4.0 |       '@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 |       blake3-wasm: 2.1.5 | ||||||
|       esbuild: 0.25.2 |       esbuild: 0.25.2 | ||||||
|       miniflare: 4.20250416.0 |       miniflare: 4.20250417.0 | ||||||
|       path-to-regexp: 6.3.0 |       path-to-regexp: 6.3.0 | ||||||
|       unenv: 2.0.0-rc.15 |       unenv: 2.0.0-rc.15 | ||||||
|       workerd: 1.20250416.0 |       workerd: 1.20250417.0 | ||||||
|     optionalDependencies: |     optionalDependencies: | ||||||
|       fsevents: 2.3.3 |       fsevents: 2.3.3 | ||||||
|       sharp: 0.33.5 |       sharp: 0.33.5 | ||||||
|   | |||||||
| @@ -32,16 +32,16 @@ export default function ErrorPage({ | |||||||
| 				</div> | 				</div> | ||||||
| 				<h1 className="text-2xl font-bold">Something went wrong</h1> | 				<h1 className="text-2xl font-bold">Something went wrong</h1> | ||||||
| 				<p className="text-muted-foreground"> | 				<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> | 				</p> | ||||||
| 				<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4"> | 				<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4"> | ||||||
| 					<Button variant="outline" onClick={() => reset()} className="cursor-pointer"> | 					<Button variant="outline" onClick={() => reset()} className="cursor-pointer"> | ||||||
| 						<RefreshCcw className="mr-2 h-4 w-4" /> | 						<RefreshCcw className="mr-2 h-4 w-4" /> | ||||||
| 						Try again | 						Retry | ||||||
| 					</Button> | 					</Button> | ||||||
| 					<Button onClick={handleGoBack} className="cursor-pointer"> | 					<Button onClick={handleGoBack} className="cursor-pointer"> | ||||||
| 						<ArrowLeft className="mr-2 h-4 w-4" /> | 						<ArrowLeft className="mr-2 h-4 w-4" /> | ||||||
| 						Go back | 						Back | ||||||
| 					</Button> | 					</Button> | ||||||
| 				</div> | 				</div> | ||||||
| 				{error.digest && <p className="text-xs text-muted-foreground mt-6">Error ID: {error.digest}</p>} | 				{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); | 			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 { | :root { | ||||||
| @@ -199,7 +186,7 @@ | |||||||
| 	--secondary: oklch(0.31 0.03 266.71); | 	--secondary: oklch(0.31 0.03 266.71); | ||||||
| 	--secondary-foreground: oklch(0.92 0 0); | 	--secondary-foreground: oklch(0.92 0 0); | ||||||
| 	--muted: oklch(0.31 0.03 266.71); | 	--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: oklch(0.34 0.06 267.59); | ||||||
| 	--accent-foreground: oklch(0.88 0.06 254.13); | 	--accent-foreground: oklch(0.88 0.06 254.13); | ||||||
| 	--destructive: oklch(0.64 0.21 25.33); | 	--destructive: oklch(0.64 0.21 25.33); | ||||||
|   | |||||||
| @@ -2,6 +2,12 @@ import { readFile } from "node:fs/promises" | |||||||
| import { join } from "node:path" | import { join } from "node:path" | ||||||
| import { getAllIcons } from "@/lib/api" | import { getAllIcons } from "@/lib/api" | ||||||
| import { ImageResponse } from "next/og" | import { ImageResponse } from "next/og" | ||||||
|  | import { | ||||||
|  | 	SITE_NAME, | ||||||
|  | 	SITE_TAGLINE, | ||||||
|  | 	getIconDescription, | ||||||
|  | 	WEB_URL | ||||||
|  | } from "@/constants" | ||||||
|  |  | ||||||
| export const dynamic = "force-static" | export const dynamic = "force-static" | ||||||
|  |  | ||||||
| @@ -52,9 +58,9 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 				position: "relative", | 				position: "relative", | ||||||
| 				fontFamily: "Inter, system-ui, sans-serif", | 				fontFamily: "Inter, system-ui, sans-serif", | ||||||
| 				overflow: "hidden", | 				overflow: "hidden", | ||||||
| 				backgroundColor: "white", | 				backgroundColor: "#0f172a", // Dark background (slate-900) | ||||||
| 				backgroundImage: | 				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", | 				backgroundSize: "100px 100px", | ||||||
| 			}} | 			}} | ||||||
| 		> | 		> | ||||||
| @@ -67,7 +73,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 					width: 400, | 					width: 400, | ||||||
| 					height: 400, | 					height: 400, | ||||||
| 					borderRadius: "50%", | 					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)", | 					filter: "blur(80px)", | ||||||
| 					zIndex: 2, | 					zIndex: 2, | ||||||
| 				}} | 				}} | ||||||
| @@ -80,7 +86,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 					width: 500, | 					width: 500, | ||||||
| 					height: 500, | 					height: 500, | ||||||
| 					borderRadius: "50%", | 					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)", | 					filter: "blur(100px)", | ||||||
| 					zIndex: 2, | 					zIndex: 2, | ||||||
| 				}} | 				}} | ||||||
| @@ -109,8 +115,8 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 						width: 320, | 						width: 320, | ||||||
| 						height: 320, | 						height: 320, | ||||||
| 						borderRadius: 32, | 						borderRadius: 32, | ||||||
| 						background: "white", | 						background: "#1e293b", // Dark container (slate-800) | ||||||
| 						boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)", | 						boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)", | ||||||
| 						padding: 30, | 						padding: 30, | ||||||
| 						flexShrink: 0, | 						flexShrink: 0, | ||||||
| 						position: "relative", | 						position: "relative", | ||||||
| @@ -121,7 +127,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 						style={{ | 						style={{ | ||||||
| 							position: "absolute", | 							position: "absolute", | ||||||
| 							inset: 0, | 							inset: 0, | ||||||
| 							background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)", | 							background: "linear-gradient(145deg, #1e293b 0%, #0f172a 100%)", | ||||||
| 							zIndex: 0, | 							zIndex: 0, | ||||||
| 						}} | 						}} | ||||||
| 					/> | 					/> | ||||||
| @@ -134,7 +140,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 							objectFit: "contain", | 							objectFit: "contain", | ||||||
| 							position: "relative", | 							position: "relative", | ||||||
| 							zIndex: 1, | 							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> | 				</div> | ||||||
| @@ -154,7 +160,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 							display: "flex", | 							display: "flex", | ||||||
| 							fontSize: 64, | 							fontSize: 64, | ||||||
| 							fontWeight: 800, | 							fontWeight: 800, | ||||||
| 							color: "#0f172a", | 							color: "#f8fafc", // Light text for dark background (slate-50) | ||||||
| 							lineHeight: 1.1, | 							lineHeight: 1.1, | ||||||
| 							letterSpacing: "-0.02em", | 							letterSpacing: "-0.02em", | ||||||
| 						}} | 						}} | ||||||
| @@ -167,14 +173,14 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 							display: "flex", | 							display: "flex", | ||||||
| 							fontSize: 32, | 							fontSize: 32, | ||||||
| 							fontWeight: 500, | 							fontWeight: 500, | ||||||
| 							color: "#64748b", | 							color: "#94a3b8", // Muted text (slate-400) | ||||||
| 							lineHeight: 1.4, | 							lineHeight: 1.4, | ||||||
| 							position: "relative", | 							position: "relative", | ||||||
| 							paddingLeft: 16, | 							paddingLeft: 16, | ||||||
| 							borderLeft: "4px solid #94a3b8", | 							borderLeft: "4px solid #64748b", // slate-500 | ||||||
| 						}} | 						}} | ||||||
| 					> | 					> | ||||||
| 						Amongst {totalIcons} other high-quality dashboard icons | 						{getIconDescription(formattedIconName, totalIcons)} | ||||||
| 					</div> | 					</div> | ||||||
|  |  | ||||||
| 					<div | 					<div | ||||||
| @@ -191,14 +197,14 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 									display: "flex", | 									display: "flex", | ||||||
| 									alignItems: "center", | 									alignItems: "center", | ||||||
| 									justifyContent: "center", | 									justifyContent: "center", | ||||||
| 									backgroundColor: "#f1f5f9", | 									backgroundColor: "#334155", // slate-700 | ||||||
| 									color: "#475569", | 									color: "#e2e8f0", // slate-200 | ||||||
| 									border: "2px solid #e2e8f0", | 									border: "2px solid #475569", // slate-600 | ||||||
| 									borderRadius: 12, | 									borderRadius: 12, | ||||||
| 									padding: "8px 16px", | 									padding: "8px 16px", | ||||||
| 									fontSize: 18, | 									fontSize: 18, | ||||||
| 									fontWeight: 600, | 									fontWeight: 600, | ||||||
| 									boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", | 									boxShadow: "0 1px 2px rgba(0, 0, 0, 0.2)", | ||||||
| 								}} | 								}} | ||||||
| 							> | 							> | ||||||
| 								{format} | 								{format} | ||||||
| @@ -219,8 +225,8 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 					display: "flex", | 					display: "flex", | ||||||
| 					alignItems: "center", | 					alignItems: "center", | ||||||
| 					justifyContent: "center", | 					justifyContent: "center", | ||||||
| 					background: "#ffffff", | 					background: "#1e293b", // slate-800 | ||||||
| 					borderTop: "2px solid rgba(0, 0, 0, 0.05)", | 					borderTop: "2px solid rgba(255, 255, 255, 0.1)", | ||||||
| 					zIndex: 20, | 					zIndex: 20, | ||||||
| 				}} | 				}} | ||||||
| 			> | 			> | ||||||
| @@ -229,7 +235,7 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 						display: "flex", | 						display: "flex", | ||||||
| 						fontSize: 24, | 						fontSize: 24, | ||||||
| 						fontWeight: 600, | 						fontWeight: 600, | ||||||
| 						color: "#334155", | 						color: "#e2e8f0", // slate-200 | ||||||
| 						alignItems: "center", | 						alignItems: "center", | ||||||
| 						gap: 10, | 						gap: 10, | ||||||
| 					}} | 					}} | ||||||
| @@ -239,11 +245,11 @@ export default async function Image({ params }: { params: { icon: string } }) { | |||||||
| 							width: 8, | 							width: 8, | ||||||
| 							height: 8, | 							height: 8, | ||||||
| 							borderRadius: "50%", | 							borderRadius: "50%", | ||||||
| 							backgroundColor: "#3b82f6", | 							backgroundColor: "#3b82f6", // blue-500 | ||||||
| 							marginRight: 4, | 							marginRight: 4, | ||||||
| 						}} | 						}} | ||||||
| 					/> | 					/> | ||||||
| 					dashboardicons.com | 					{WEB_URL.replace("https://", "")} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div>, | 		</div>, | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
| import { IconDetails } from "@/components/icon-details" | 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 { getAllIcons, getAuthorData } from "@/lib/api" | ||||||
| import type { Metadata, ResolvingMetadata } from "next" | import type { Metadata, ResolvingMetadata } from "next" | ||||||
|  | import Script from "next/script" | ||||||
| import { notFound } from "next/navigation" | import { notFound } from "next/navigation" | ||||||
|  |  | ||||||
| export const dynamicParams = false | 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)) | 		.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) | ||||||
| 		.join(" ") | 		.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 { | 	return { | ||||||
| 		title: `${formattedIconName} Icon | Dashboard Icons`, | 		title, | ||||||
| 		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.`, | 		description, | ||||||
| 		assets: [iconImageUrl], | 		assets: [iconImageUrl], | ||||||
| 		category: "icons", | 		category: "Icons", | ||||||
| 		keywords: [ | 		keywords: ICON_DETAIL_KEYWORDS(formattedIconName), | ||||||
| 			`${formattedIconName} icon`, |  | ||||||
| 			"dashboard icon", |  | ||||||
| 			"service icon", |  | ||||||
| 			"application icon", |  | ||||||
| 			"tool icon", |  | ||||||
| 			"web dashboard", |  | ||||||
| 			"app directory", |  | ||||||
| 		], |  | ||||||
| 		icons: { | 		icons: { | ||||||
| 			icon: iconImageUrl, | 			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: { | 		robots: { | ||||||
| 			index: true, | 			index: true, | ||||||
| 			follow: true, | 			follow: true, | ||||||
| 		}, | 		}, | ||||||
| 		openGraph: { | 		openGraph: { | ||||||
| 			title: `${formattedIconName} Icon | Dashboard Icons`, | 			title: fullTitle, | ||||||
| 			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.`, | 			description, | ||||||
| 			type: "article", | 			type: "article", | ||||||
| 			url: pageUrl, | 			url: pageUrl, | ||||||
| 			authors: [authorName], | 			authors: [authorName], | ||||||
| 			publishedTime: updateDate.toISOString(), | 			publishedTime: updateDate.toISOString(), | ||||||
| 			modifiedTime: updateDate.toISOString(), | 			modifiedTime: updateDate.toISOString(), | ||||||
| 			section: "Icons", | 			section: "Icons", | ||||||
| 			tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"], | 			tags: [formattedIconName, ...ICON_DETAIL_KEYWORDS(formattedIconName)], | ||||||
| 		}, | 		}, | ||||||
| 		twitter: { | 		twitter: { | ||||||
| 			card: "summary_large_image", | 			card: "summary_large_image", | ||||||
| 			title: `${formattedIconName} Icon | Dashboard Icons`, | 			title: fullTitle, | ||||||
| 			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.`, | 			description, | ||||||
| 			images: [iconImageUrl], | 			images: [iconImageUrl], | ||||||
| 		}, | 		}, | ||||||
| 		alternates: { | 		alternates: { | ||||||
| @@ -87,6 +85,9 @@ export async function generateMetadata({ params, searchParams }: Props, parent: | |||||||
| 				webp: `${BASE_URL}/webp/${icon}.webp`, | 				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 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 { Separator } from "@/components/ui/separator" | ||||||
| import { BASE_URL } from "@/constants" | import { BASE_URL } from "@/constants" | ||||||
| import type { Icon, IconSearchProps } from "@/types/icons" | 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 { useTheme } from "next-themes" | ||||||
| import Image from "next/image" | import Image from "next/image" | ||||||
| import Link from "next/link" | import Link from "next/link" | ||||||
| @@ -27,24 +27,82 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation" | |||||||
| import posthog from "posthog-js" | import posthog from "posthog-js" | ||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" | import { useCallback, useEffect, useMemo, useRef, useState } from "react" | ||||||
| import { toast } from "sonner" | import { toast } from "sonner" | ||||||
|  | import { motion, AnimatePresence } from "framer-motion" | ||||||
|  |  | ||||||
| type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest" | 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) { | export function IconSearch({ icons }: IconSearchProps) { | ||||||
| 	const searchParams = useSearchParams() | 	const searchParams = useSearchParams() | ||||||
| 	const initialQuery = searchParams.get("q") | 	const initialQuery = searchParams.get("q") | ||||||
| 	const initialCategories = searchParams.getAll("category") | 	const initialCategories = searchParams.getAll("category") | ||||||
| 	const initialSort = (searchParams.get("sort") as SortOption) || "relevance" | 	const initialSort = (searchParams.get("sort") as SortOption) || "relevance" | ||||||
|  | 	const initialPage = Number(searchParams.get("page") || "1") | ||||||
| 	const router = useRouter() | 	const router = useRouter() | ||||||
| 	const pathname = usePathname() | 	const pathname = usePathname() | ||||||
| 	const [searchQuery, setSearchQuery] = useState(initialQuery ?? "") | 	const [searchQuery, setSearchQuery] = useState(initialQuery ?? "") | ||||||
| 	const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "") | 	const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "") | ||||||
| 	const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? []) | 	const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? []) | ||||||
| 	const [sortOption, setSortOption] = useState<SortOption>(initialSort) | 	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 timeoutRef = useRef<NodeJS.Timeout | null>(null) | ||||||
| 	const { resolvedTheme } = useTheme() | 	const { resolvedTheme } = useTheme() | ||||||
| 	const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false) | 	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(() => { | 	useEffect(() => { | ||||||
| 		const timer = setTimeout(() => { | 		const timer = setTimeout(() => { | ||||||
| 			setDebouncedQuery(searchQuery) | 			setDebouncedQuery(searchQuery) | ||||||
| @@ -138,7 +196,7 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 	}, [filterIcons, debouncedQuery, selectedCategories, sortOption]) | 	}, [filterIcons, debouncedQuery, selectedCategories, sortOption]) | ||||||
|  |  | ||||||
| 	const updateResults = useCallback( | 	const updateResults = useCallback( | ||||||
| 		(query: string, categories: string[], sort: SortOption) => { | 		(query: string, categories: string[], sort: SortOption, page = 1) => { | ||||||
| 			const params = new URLSearchParams() | 			const params = new URLSearchParams() | ||||||
| 			if (query) params.set("q", query) | 			if (query) params.set("q", query) | ||||||
|  |  | ||||||
| @@ -152,6 +210,11 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 				params.set("sort", sort) | 				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 | 			const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname | ||||||
| 			router.push(newUrl, { scroll: false }) | 			router.push(newUrl, { scroll: false }) | ||||||
| 		}, | 		}, | ||||||
| @@ -197,11 +260,20 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 		[updateResults, searchQuery, selectedCategories], | 		[updateResults, searchQuery, selectedCategories], | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
|  | 	const handlePageChange = useCallback( | ||||||
|  | 		(page: number) => { | ||||||
|  | 			setCurrentPage(page); | ||||||
|  | 			updateResults(searchQuery, selectedCategories, sortOption, page); | ||||||
|  | 		}, | ||||||
|  | 		[updateResults, searchQuery, selectedCategories, sortOption], | ||||||
|  | 	) | ||||||
|  |  | ||||||
| 	const clearFilters = useCallback(() => { | 	const clearFilters = useCallback(() => { | ||||||
| 		setSearchQuery("") | 		setSearchQuery("") | ||||||
| 		setSelectedCategories([]) | 		setSelectedCategories([]) | ||||||
| 		setSortOption("relevance") | 		setSortOption("relevance") | ||||||
| 		updateResults("", [], "relevance") | 		setCurrentPage(1) | ||||||
|  | 		updateResults("", [], "relevance", 1) | ||||||
| 	}, [updateResults]) | 	}, [updateResults]) | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| @@ -228,11 +300,11 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 	const getSortLabel = (sort: SortOption) => { | 	const getSortLabel = (sort: SortOption) => { | ||||||
| 		switch (sort) { | 		switch (sort) { | ||||||
| 			case "relevance": | 			case "relevance": | ||||||
| 				return "Best match" | 				return "Relevance" | ||||||
| 			case "alphabetical-asc": | 			case "alphabetical-asc": | ||||||
| 				return "A to Z" | 				return "Name (A-Z)" | ||||||
| 			case "alphabetical-desc": | 			case "alphabetical-desc": | ||||||
| 				return "Z to A" | 				return "Name (Z-A)" | ||||||
| 			case "newest": | 			case "newest": | ||||||
| 				return "Newest first" | 				return "Newest first" | ||||||
| 			default: | 			default: | ||||||
| @@ -265,7 +337,7 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 					</div> | 					</div> | ||||||
| 					<Input | 					<Input | ||||||
| 						type="search" | 						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" | 						className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base   border-border shadow-sm" | ||||||
| 						value={searchQuery} | 						value={searchQuery} | ||||||
| 						onChange={(e) => handleSearch(e.target.value)} | 						onChange={(e) => handleSearch(e.target.value)} | ||||||
| @@ -277,18 +349,18 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 					{/* Filter dropdown */} | 					{/* Filter dropdown */} | ||||||
| 					<DropdownMenu> | 					<DropdownMenu> | ||||||
| 						<DropdownMenuTrigger asChild> | 						<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" /> | 								<Filter className="h-4 w-4 mr-2" /> | ||||||
| 								<span>Filter</span> | 								<span>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span> | ||||||
| 								{selectedCategories.length > 0 && ( |  | ||||||
| 									<Badge variant="secondary" className="ml-2 px-1.5"> |  | ||||||
| 										{selectedCategories.length} |  | ||||||
| 									</Badge> |  | ||||||
| 								)} |  | ||||||
| 							</Button> | 							</Button> | ||||||
| 						</DropdownMenuTrigger> | 						</DropdownMenuTrigger> | ||||||
| 						<DropdownMenuContent align="start" className="w-64 sm:w-56"> | 						<DropdownMenuContent align="start" className="w-64 sm:w-56"> | ||||||
| 							<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel> | 							<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel> | ||||||
| 							<DropdownMenuSeparator /> | 							<DropdownMenuSeparator /> | ||||||
|  |  | ||||||
| 							<div className="max-h-[40vh] overflow-y-auto p-1"> | 							<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" | 										className="cursor-pointer  focus: focus:bg-rose-50 dark:focus:bg-rose-950/20" | ||||||
| 									> | 									> | ||||||
| 										Clear all filters | 										Clear categories | ||||||
| 									</DropdownMenuItem> | 									</DropdownMenuItem> | ||||||
| 								</> | 								</> | ||||||
| 							)} | 							)} | ||||||
| @@ -330,18 +402,18 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 							</Button> | 							</Button> | ||||||
| 						</DropdownMenuTrigger> | 						</DropdownMenuTrigger> | ||||||
| 						<DropdownMenuContent align="start" className="w-56"> | 						<DropdownMenuContent align="start" className="w-56"> | ||||||
| 							<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel> | 							<DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel> | ||||||
| 							<DropdownMenuSeparator /> | 							<DropdownMenuSeparator /> | ||||||
| 							<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}> | 							<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}> | ||||||
| 								<DropdownMenuRadioItem value="relevance" className="cursor-pointer"> | 								<DropdownMenuRadioItem value="relevance" className="cursor-pointer"> | ||||||
| 									<Search className="h-4 w-4 mr-2" /> | 									<Search className="h-4 w-4 mr-2" /> | ||||||
| 									Best match | 									Relevance | ||||||
| 								</DropdownMenuRadioItem> | 								</DropdownMenuRadioItem> | ||||||
| 								<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer"> | 								<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> | ||||||
| 								<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer"> | 								<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> | ||||||
| 								<DropdownMenuRadioItem value="newest" className="cursor-pointer"> | 								<DropdownMenuRadioItem value="newest" className="cursor-pointer"> | ||||||
| 									<Calendar className="h-4 w-4 mr-2" /> | 									<Calendar className="h-4 w-4 mr-2" /> | ||||||
| @@ -353,9 +425,15 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
|  |  | ||||||
| 					{/* Clear all button */} | 					{/* Clear all button */} | ||||||
| 					{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && ( | 					{(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" /> | 							<X className="h-4 w-4 mr-2" /> | ||||||
| 							<span>Clear all</span> | 							<span>Reset</span> | ||||||
| 						</Button> | 						</Button> | ||||||
| 					)} | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| @@ -363,7 +441,7 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 				{/* Active filter badges */} | 				{/* Active filter badges */} | ||||||
| 				{selectedCategories.length > 0 && ( | 				{selectedCategories.length > 0 && ( | ||||||
| 					<div className="flex flex-wrap items-center gap-2 mt-2"> | 					<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"> | 						<div className="flex flex-wrap gap-2"> | ||||||
| 							{selectedCategories.map((category) => ( | 							{selectedCategories.map((category) => ( | ||||||
| 								<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1"> | 								<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" | 							className="text-xs h-7 px-2 cursor-pointer" | ||||||
| 						> | 						> | ||||||
| 							Clear all | 							Clear | ||||||
| 						</Button> | 						</Button> | ||||||
| 					</div> | 					</div> | ||||||
| 				)} | 				)} | ||||||
| @@ -400,27 +478,33 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 			{filteredIcons.length === 0 ? ( | 			{filteredIcons.length === 0 ? ( | ||||||
| 				<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center"> | 				<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center"> | ||||||
| 					<div className="text-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> | 					</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> | 				</div> | ||||||
| 			) : ( | 			) : ( | ||||||
| 				<> | 				<> | ||||||
| @@ -435,7 +519,14 @@ export function IconSearch({ icons }: IconSearchProps) { | |||||||
| 						</div> | 						</div> | ||||||
| 					</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({ | function IconCard({ | ||||||
| 	name, | 	name, | ||||||
| 	data: iconData, | 	data: iconData, | ||||||
| 	matchedAlias, |  | ||||||
| }: { | }: { | ||||||
| 	name: string | 	name: string | ||||||
| 	data: Icon | 	data: Icon | ||||||
| 	matchedAlias?: string | null |  | ||||||
| }) { | }) { | ||||||
| 	return ( | 	return ( | ||||||
| 		<MagicCard className="rounded-md shadow-md"> | 		<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 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"> | 				<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> | ||||||
| 					<Image | 					<Image | ||||||
| 						src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`} | 						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" | 						className="object-contain p-1 group-hover:scale-110 transition-transform duration-300" | ||||||
| 					/> | 					/> | ||||||
| 				</div> | 				</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, " ")} | 					{name.replace(/-/g, " ")} | ||||||
| 				</span> | 				</span> | ||||||
|  |  | ||||||
| 				{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>} |  | ||||||
| 			</Link> | 			</Link> | ||||||
| 		</MagicCard> | 		</MagicCard> | ||||||
| 	) | 	) | ||||||
| @@ -475,17 +562,253 @@ function IconCard({ | |||||||
| interface IconsGridProps { | interface IconsGridProps { | ||||||
| 	filteredIcons: { name: string; data: Icon }[] | 	filteredIcons: { name: string; data: Icon }[] | ||||||
| 	matchedAliases: Record<string, string> | 	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 ( | 	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"> | 			<AnimatePresence mode="wait"> | ||||||
| 				{filteredIcons.slice(0, 120).map(({ name, data }) => ( | 				<motion.div | ||||||
| 					<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} /> | 					key={currentPage} | ||||||
| 				))} | 					initial={{ opacity: 0, y: 20 }} | ||||||
| 			</div> | 					animate={{ opacity: 1, y: 0 }} | ||||||
| 			{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>} | 					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 { getIconsArray } from "@/lib/api" | ||||||
| import type { Metadata } from "next" | import type { Metadata } from "next" | ||||||
| import { IconSearch } from "./components/icon-search" | import { IconSearch } from "./components/icon-search" | ||||||
|  | import { StructuredData } from "@/components/structured-data" | ||||||
|  |  | ||||||
| export async function generateMetadata(): Promise<Metadata> { | export async function generateMetadata(): Promise<Metadata> { | ||||||
| 	const icons = await getIconsArray() | 	const icons = await getIconsArray() | ||||||
| 	const totalIcons = icons.length | 	const totalIcons = icons.length | ||||||
|  |  | ||||||
|  | 	const title = `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME}` | ||||||
|  | 	const description = getBrowseDescription(totalIcons) | ||||||
|  |  | ||||||
| 	return { | 	return { | ||||||
| 		title: "Browse Icons | Free Dashboard Icons", | 		title, | ||||||
| 		description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | 		description, | ||||||
| 		keywords: [ | 		keywords: BROWSE_KEYWORDS, | ||||||
| 			"browse icons", |  | ||||||
| 			"dashboard icons", |  | ||||||
| 			"icon search", |  | ||||||
| 			"service icons", |  | ||||||
| 			"application icons", |  | ||||||
| 			"tool icons", |  | ||||||
| 			"web dashboard", |  | ||||||
| 			"app directory", |  | ||||||
| 		], |  | ||||||
| 		openGraph: { | 		openGraph: { | ||||||
| 			title: "Browse Icons | Free Dashboard Icons", | 			title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`, | ||||||
| 			description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | 			description, | ||||||
| 			type: "website", | 			type: "website", | ||||||
| 			url: `${BASE_URL}/icons`, | 			url: `${WEB_URL}/icons`, | ||||||
| 			images: [ | 			images: [DEFAULT_OG_IMAGE], | ||||||
| 				{ |  | ||||||
| 					url: "/og-image.png", |  | ||||||
| 					width: 1200, |  | ||||||
| 					height: 630, |  | ||||||
| 					alt: "Browse Dashboard Icons Collection", |  | ||||||
| 					type: "image/png", |  | ||||||
| 				}, |  | ||||||
| 			], |  | ||||||
| 		}, | 		}, | ||||||
| 		twitter: { | 		twitter: { | ||||||
| 			card: "summary_large_image", | 			card: "summary_large_image", | ||||||
| 			title: "Browse Icons | Free Dashboard Icons", | 			title: `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`, | ||||||
| 			description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | 			description, | ||||||
| 			images: ["/og-image-browse.png"], | 			images: [DEFAULT_OG_IMAGE.url], | ||||||
| 		}, | 		}, | ||||||
| 		alternates: { | 		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() { | export default async function IconsPage() { | ||||||
| 	const icons = await getIconsArray() | 	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> | 			</div> | ||||||
| 		</div> | 		</> | ||||||
| 	) | 	) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,12 +2,13 @@ import { PostHogProvider } from "@/components/PostHogProvider" | |||||||
| import { Footer } from "@/components/footer" | import { Footer } from "@/components/footer" | ||||||
| import { HeaderWrapper } from "@/components/header-wrapper" | import { HeaderWrapper } from "@/components/header-wrapper" | ||||||
| import { LicenseNotice } from "@/components/license-notice" | import { LicenseNotice } from "@/components/license-notice" | ||||||
|  | import { WebsiteStructuredData } from "@/components/structured-data" | ||||||
| import { getTotalIcons } from "@/lib/api" | import { getTotalIcons } from "@/lib/api" | ||||||
| import type { Metadata, Viewport } from "next" | import type { Metadata, Viewport } from "next" | ||||||
| import { Inter } from "next/font/google" | import { Inter } from "next/font/google" | ||||||
| import { Toaster } from "sonner" | import { Toaster } from "sonner" | ||||||
| import "./globals.css" | 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" | import { ThemeProvider } from "./theme-provider" | ||||||
|  |  | ||||||
| const inter = Inter({ | const inter = Inter({ | ||||||
| @@ -27,12 +28,13 @@ export const viewport: Viewport = { | |||||||
|  |  | ||||||
| export async function generateMetadata(): Promise<Metadata> { | export async function generateMetadata(): Promise<Metadata> { | ||||||
| 	const { totalIcons } = await getTotalIcons() | 	const { totalIcons } = await getTotalIcons() | ||||||
|  | 	const description = getDescription(totalIcons) | ||||||
|  |  | ||||||
| 	return { | 	return { | ||||||
| 		metadataBase: new URL("https://dashboardicons.com"), | 		metadataBase: new URL(WEB_URL), | ||||||
| 		title: websiteTitle, | 		title: websiteTitle, | ||||||
| 		description: getDescription(totalIcons), | 		description, | ||||||
| 		keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], | 		keywords: DEFAULT_KEYWORDS, | ||||||
| 		robots: { | 		robots: { | ||||||
| 			index: true, | 			index: true, | ||||||
| 			follow: true, | 			follow: true, | ||||||
| @@ -42,33 +44,23 @@ export async function generateMetadata(): Promise<Metadata> { | |||||||
| 			googleBot: "index, follow", | 			googleBot: "index, follow", | ||||||
| 		}, | 		}, | ||||||
| 		openGraph: { | 		openGraph: { | ||||||
| 			siteName: "Dashboard Icons", | 			siteName: SITE_NAME, | ||||||
| 			type: "website", | 			type: "website", | ||||||
| 			locale: "en_US", | 			locale: "en_US", | ||||||
| 			title: websiteTitle, | 			title: websiteFullTitle, | ||||||
| 			description: getDescription(totalIcons), | 			description, | ||||||
| 			url: "https://dashboardicons.com", | 			url: WEB_URL, | ||||||
| 			images: [ | 			images: [DEFAULT_OG_IMAGE], | ||||||
| 				{ |  | ||||||
| 					url: "/og-image.png", |  | ||||||
| 					width: 1200, |  | ||||||
| 					height: 630, |  | ||||||
| 					alt: "Dashboard Icons", |  | ||||||
| 					type: "image/png", |  | ||||||
| 				}, |  | ||||||
| 			], |  | ||||||
| 		}, | 		}, | ||||||
| 		twitter: { | 		twitter: { | ||||||
| 			card: "summary_large_image", | 			card: "summary_large_image", | ||||||
| 			site: "@homarr_app", | 			title: websiteFullTitle, | ||||||
| 			creator: "@homarr_app", | 			description, | ||||||
| 			title: websiteTitle, | 			images: [DEFAULT_OG_IMAGE.url], | ||||||
| 			description: getDescription(totalIcons), |  | ||||||
| 			images: ["/og-image.png"], |  | ||||||
| 		}, | 		}, | ||||||
| 		applicationName: "Dashboard Icons", | 		applicationName: SITE_NAME, | ||||||
| 		appleWebApp: { | 		appleWebApp: { | ||||||
| 			title: "Dashboard Icons", | 			title: SITE_NAME, | ||||||
| 			statusBarStyle: "default", | 			statusBarStyle: "default", | ||||||
| 			capable: true, | 			capable: true, | ||||||
| 		}, | 		}, | ||||||
| @@ -88,14 +80,29 @@ export async function generateMetadata(): Promise<Metadata> { | |||||||
| 			], | 			], | ||||||
| 		}, | 		}, | ||||||
| 		manifest: "/site.webmanifest", | 		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 ( | 	return ( | ||||||
| 		<html lang="en" suppressHydrationWarning> | 		<html lang="en" suppressHydrationWarning> | ||||||
| 			<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}> | 			<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}> | ||||||
| 				<PostHogProvider> | 				<PostHogProvider> | ||||||
|  | 					<WebsiteStructuredData | ||||||
|  | 						websiteSchema={websiteSchema} | ||||||
|  | 						organizationSchema={ORGANIZATION_SCHEMA} | ||||||
|  | 					/> | ||||||
| 					<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange> | 					<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange> | ||||||
| 						<HeaderWrapper /> | 						<HeaderWrapper /> | ||||||
| 						<main className="flex-grow">{children}</main> | 						<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"> | 					<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" /> | 						<AlertTriangle className="w-8 h-8" /> | ||||||
| 					</div> | 					</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"> | 					<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> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| @@ -25,16 +25,16 @@ export default function NotFound({ | |||||||
| 					<Button asChild variant="outline"> | 					<Button asChild variant="outline"> | ||||||
| 						<Link href="/icons"> | 						<Link href="/icons"> | ||||||
| 							<ArrowLeft className="mr-2 h-4 w-4" /> | 							<ArrowLeft className="mr-2 h-4 w-4" /> | ||||||
| 							Back to all icons | 							Back to icons | ||||||
| 						</Link> | 						</Link> | ||||||
| 					</Button> | 					</Button> | ||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				<div className="border-t border-border pt-8 mt-8"> | 				<div className="border-t border-border pt-8 mt-8"> | ||||||
| 					<div className="text-center mb-6"> | 					<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"> | 						<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> | 						</p> | ||||||
| 					</div> | 					</div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,42 +1,37 @@ | |||||||
| import { HeroSection } from "@/components/hero" | import { HeroSection } from "@/components/hero" | ||||||
| import { RecentlyAddedIcons } from "@/components/recently-added-icons" | 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 { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api" | ||||||
| import type { Metadata } from "next" | import type { Metadata } from "next" | ||||||
|  |  | ||||||
| export async function generateMetadata(): Promise<Metadata> { | export async function generateMetadata(): Promise<Metadata> { | ||||||
| 	const { totalIcons } = await getTotalIcons() | 	const { totalIcons } = await getTotalIcons() | ||||||
|  | 	const description = getHomeDescription(totalIcons) | ||||||
|  |  | ||||||
| 	return { | 	return { | ||||||
| 		title: websiteTitle, | 		title: websiteTitle, | ||||||
| 		description: getDescription(totalIcons), | 		description, | ||||||
| 		keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"], | 		keywords: DEFAULT_KEYWORDS, | ||||||
| 		robots: { | 		robots: { | ||||||
| 			index: true, | 			index: true, | ||||||
| 			follow: true, | 			follow: true, | ||||||
| 		}, | 		}, | ||||||
| 		openGraph: { | 		openGraph: { | ||||||
| 			title: websiteTitle, | 			title: websiteFullTitle, | ||||||
| 			description: getDescription(totalIcons), | 			description, | ||||||
| 			type: "website", | 			type: "website", | ||||||
| 			url: BASE_URL, | 			url: WEB_URL, | ||||||
| 			images: [ | 			images: [DEFAULT_OG_IMAGE], | ||||||
| 				{ |  | ||||||
| 					url: "/og-image.png", |  | ||||||
| 					width: 1200, |  | ||||||
| 					height: 630, |  | ||||||
| 					alt: "Dashboard Icons", |  | ||||||
| 				}, |  | ||||||
| 			], |  | ||||||
| 		}, | 		}, | ||||||
| 		twitter: { | 		twitter: { | ||||||
| 			title: websiteTitle, | 			title: websiteFullTitle, | ||||||
| 			description: getDescription(totalIcons), | 			description, | ||||||
| 			card: "summary_large_image", | 			card: "summary_large_image", | ||||||
| 			images: ["/og-image.png"], | 			images: [DEFAULT_OG_IMAGE.url], | ||||||
| 		}, | 		}, | ||||||
| 		alternates: { | 		alternates: { | ||||||
| 			canonical: BASE_URL, | 			canonical: WEB_URL, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| @@ -54,9 +49,11 @@ export default async function Home() { | |||||||
| 	const stars = await getGitHubStars() | 	const stars = await getGitHubStars() | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div className="flex flex-col min-h-screen"> | 		<> | ||||||
| 			<HeroSection totalIcons={totalIcons} stars={stars} /> | 			<div className="flex flex-col min-h-screen"> | ||||||
| 			<RecentlyAddedIcons icons={recentIcons} /> | 				<HeroSection totalIcons={totalIcons} stars={stars} /> | ||||||
| 		</div> | 				<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"> | 					<div className="flex flex-col gap-3"> | ||||||
| 						<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3> | 						<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3> | ||||||
| 						<p className="text-sm text-muted-foreground leading-relaxed"> | 						<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> | 						</p> | ||||||
| 					</div> | 					</div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,42 +3,14 @@ | |||||||
| import { IconSubmissionForm } from "@/components/icon-submission-form" | import { IconSubmissionForm } from "@/components/icon-submission-form" | ||||||
| import { ThemeSwitcher } from "@/components/theme-switcher" | import { ThemeSwitcher } from "@/components/theme-switcher" | ||||||
| import { REPO_PATH } from "@/constants" | import { REPO_PATH } from "@/constants" | ||||||
| import { getIconsArray } from "@/lib/api" |  | ||||||
| import type { IconWithName } from "@/types/icons" |  | ||||||
| import { motion } from "framer-motion" | import { motion } from "framer-motion" | ||||||
| import { Github, Search } from "lucide-react" | import { Github } from "lucide-react" | ||||||
| import Link from "next/link" | import Link from "next/link" | ||||||
| import { useEffect, useState } from "react" |  | ||||||
| import { CommandMenu } from "./command-menu" |  | ||||||
| import { HeaderNav } from "./header-nav" | import { HeaderNav } from "./header-nav" | ||||||
| import { Button } from "./ui/button" | import { Button } from "./ui/button" | ||||||
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" | ||||||
|  |  | ||||||
| export function Header() { | 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 ( | 	return ( | ||||||
| 		<motion.header | 		<motion.header | ||||||
| 			className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50" | 			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> | 				</div> | ||||||
| 				<div className="flex items-center gap-2 md:gap-4"> | 				<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"> | 					<div className="hidden md:flex items-center gap-2 md:gap-4"> | ||||||
| 						<IconSubmissionForm /> | 						<IconSubmissionForm /> | ||||||
| 						<TooltipProvider> | 						<TooltipProvider> | ||||||
| @@ -106,9 +54,6 @@ export function Header() { | |||||||
| 					<ThemeSwitcher /> | 					<ThemeSwitcher /> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			{/* Single instance of CommandMenu */} |  | ||||||
| 			{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />} |  | ||||||
| 		</motion.header> | 		</motion.header> | ||||||
| 	) | 	) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -205,13 +205,61 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars: | |||||||
| 				/> | 				/> | ||||||
| 			</div> | 			</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 "> | 				<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 "> | 					<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 | 						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 /> | 						<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> | 						<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText> | ||||||
| 					</h1> | 					</h1> | ||||||
|  |  | ||||||
| @@ -224,7 +272,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars: | |||||||
| 						<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} /> | 						<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"> | 						<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-500"> | ||||||
| 							<Link href="/icons"> | 							<Link href="/icons"> | ||||||
| 								<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton> | 								<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton> | ||||||
| 							</Link> | 							</Link> | ||||||
| 							<GiveUsAStarButton stars={stars} /> | 							<GiveUsAStarButton stars={stars} /> | ||||||
| 							<GiveUsMoneyButton /> | 							<GiveUsMoneyButton /> | ||||||
| @@ -449,12 +497,12 @@ export function GiveUsMoneyButton() { | |||||||
| 					<div className="flex justify-between items-center pt-2"> | 					<div className="flex justify-between items-center pt-2"> | ||||||
| 						<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer"> | 						<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer"> | ||||||
| 							<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90"> | 							<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90"> | ||||||
| 								Donate | 								Support | ||||||
| 							</Button> | 							</Button> | ||||||
| 						</Link> | 						</Link> | ||||||
| 						<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer"> | 						<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"> | 							<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" /> | 								<ExternalLink className="h-3 w-3" /> | ||||||
| 							</Button> | 							</Button> | ||||||
| 						</Link> | 						</Link> | ||||||
| @@ -478,7 +526,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro | |||||||
| 				name="q" | 				name="q" | ||||||
| 				autoFocus | 				autoFocus | ||||||
| 				type="search" | 				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" | 				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} | 				value={searchQuery} | ||||||
| 				onChange={(e) => setSearchQuery(e.target.value)} | 				onChange={(e) => setSearchQuery(e.target.value)} | ||||||
|   | |||||||
| @@ -207,6 +207,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 										size="icon" | 										size="icon" | ||||||
| 										className="h-8 w-8 rounded-lg cursor-pointer" | 										className="h-8 w-8 rounded-lg cursor-pointer" | ||||||
| 										onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)} | 										onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)} | ||||||
|  | 										aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} | ||||||
| 									> | 									> | ||||||
| 										<Download className="w-4 h-4" /> | 										<Download className="w-4 h-4" /> | ||||||
| 									</Button> | 									</Button> | ||||||
| @@ -223,6 +224,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 										size="icon" | 										size="icon" | ||||||
| 										className="h-8 w-8 rounded-lg cursor-pointer" | 										className="h-8 w-8 rounded-lg cursor-pointer" | ||||||
| 										onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)} | 										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" />} | 										{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />} | ||||||
| 									</Button> | 									</Button> | ||||||
| @@ -234,8 +236,18 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
|  |  | ||||||
| 							<Tooltip> | 							<Tooltip> | ||||||
| 								<TooltipTrigger asChild> | 								<TooltipTrigger asChild> | ||||||
| 									<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild> | 									<Button | ||||||
| 										<Link href={githubUrl} target="_blank" rel="noopener noreferrer"> | 										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" /> | 											<Github className="w-4 h-4" /> | ||||||
| 										</Link> | 										</Link> | ||||||
| 									</Button> | 									</Button> | ||||||
| @@ -252,7 +264,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return ( | 	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"> | 			<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> | ||||||
| 				{/* Left Column: Icon Info and Author */} | 				{/* Left Column: Icon Info and Author */} | ||||||
| 				<div className="lg:col-span-1"> | 				<div className="lg:col-span-1"> | ||||||
| @@ -306,7 +318,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
|  |  | ||||||
| 								{iconData.categories && iconData.categories.length > 0 && ( | 								{iconData.categories && iconData.categories.length > 0 && ( | ||||||
| 									<div> | 									<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"> | 										<div className="flex flex-wrap gap-2"> | ||||||
| 											{iconData.categories.map((category) => ( | 											{iconData.categories.map((category) => ( | ||||||
| 												<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer"> | 												<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 && ( | 								{iconData.aliases && iconData.aliases.length > 0 && ( | ||||||
| 									<div> | 									<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"> | 										<div className="flex flex-wrap gap-2"> | ||||||
| 											{iconData.aliases.map((alias) => ( | 											{iconData.aliases.map((alias) => ( | ||||||
| 												<Badge | 												<Badge | ||||||
| @@ -344,19 +356,17 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 								)} | 								)} | ||||||
|  |  | ||||||
| 								<div> | 								<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"> | 									<div className="text-xs text-muted-foreground space-y-2"> | ||||||
| 										<p> | 										<p> | ||||||
| 											Available in{" "} | 											Available in {availableFormats.length > 1 | ||||||
| 											{availableFormats.length > 1 | 												? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) ` | ||||||
| 												? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})` | 												: `${availableFormats[0].toUpperCase()} format `} | ||||||
| 												: `${availableFormats[0].toUpperCase()} format`}{" "} |  | ||||||
| 											with a base format of {iconData.base.toUpperCase()}. | 											with a base format of {iconData.base.toUpperCase()}. | ||||||
| 											{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."} | 											{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."} | ||||||
| 										</p> | 										</p> | ||||||
| 										<p> | 										<p> | ||||||
| 											Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user | 											Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")} logo. | ||||||
| 											experience. |  | ||||||
| 										</p> | 										</p> | ||||||
| 									</div> | 									</div> | ||||||
| 								</div> | 								</div> | ||||||
| @@ -412,7 +422,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 						<CardContent> | 						<CardContent> | ||||||
| 							<div className="space-y-6"> | 							<div className="space-y-6"> | ||||||
| 								<div className=""> | 								<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"> | 									<div className="flex items-center gap-2"> | ||||||
| 										<FileType className="w-4 h-4 text-blue-500" /> | 										<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> | 										<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> | ||||||
|  |  | ||||||
| 								<div className=""> | 								<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"> | 									<div className="flex flex-wrap gap-2"> | ||||||
| 										{availableFormats.map((format) => ( | 										{availableFormats.map((format) => ( | ||||||
| 											<div key={format} className="px-3 py-1.5  border border-border rounded-lg text-xs font-medium"> | 											<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 && ( | 								{iconData.colors && ( | ||||||
| 									<div className=""> | 									<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"> | 										<div className="space-y-2"> | ||||||
| 											{Object.entries(iconData.colors).map(([theme, variant]) => ( | 											{Object.entries(iconData.colors).map(([theme, variant]) => ( | ||||||
| 												<div key={theme} className="flex items-center gap-2"> | 												<div key={theme} className="flex items-center gap-2"> | ||||||
| @@ -446,7 +456,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | |||||||
| 								)} | 								)} | ||||||
|  |  | ||||||
| 								<div className=""> | 								<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> | 									<Button variant="outline" className="w-full" asChild> | ||||||
| 										<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer"> | 										<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer"> | ||||||
| 											<Github className="w-4 h-4 mr-2" /> | 											<Github className="w-4 h-4 mr-2" /> | ||||||
|   | |||||||
| @@ -11,32 +11,32 @@ import { useState } from "react" | |||||||
| export const ISSUE_TEMPLATES = [ | export const ISSUE_TEMPLATES = [ | ||||||
| 	{ | 	{ | ||||||
| 		id: "add_monochrome_icon", | 		id: "add_monochrome_icon", | ||||||
| 		name: "Add light & dark icon", | 		name: "Add light/dark icon", | ||||||
| 		description: "Submit a new icon with both light and dark versions for optimal theme compatibility.", | 		description: "Submit a new icon with light and dark versions.", | ||||||
| 		url: `${REPO_PATH}/issues/new?template=add_monochrome_icon.yml`, | 		url: `${REPO_PATH}/issues/new?template=add_monochrome_icon.yml`, | ||||||
| 	}, | 	}, | ||||||
| 	{ | 	{ | ||||||
| 		id: "add_normal_icon", | 		id: "add_normal_icon", | ||||||
| 		name: "Add normal icon", | 		name: "Add standard icon", | ||||||
| 		description: "Submit a new icon that works well across both light and dark themes.", | 		description: "Submit a new icon for both themes.", | ||||||
| 		url: `${REPO_PATH}/issues/new?template=add_normal_icon.yml`, | 		url: `${REPO_PATH}/issues/new?template=add_normal_icon.yml`, | ||||||
| 	}, | 	}, | ||||||
| 	{ | 	{ | ||||||
| 		id: "update_monochrome_icon", | 		id: "update_monochrome_icon", | ||||||
| 		name: "Update light & dark icon", | 		name: "Update light/dark icon", | ||||||
| 		description: "Improve an existing icon by updating both light and dark versions.", | 		description: "Improve or update an existing light/dark icon.", | ||||||
| 		url: `${REPO_PATH}/issues/new?template=update_monochrome_icon.yml`, | 		url: `${REPO_PATH}/issues/new?template=update_monochrome_icon.yml`, | ||||||
| 	}, | 	}, | ||||||
| 	{ | 	{ | ||||||
| 		id: "update_normal_icon", | 		id: "update_normal_icon", | ||||||
| 		name: "Update normal icon", | 		name: "Update standard icon", | ||||||
| 		description: "Improve an existing icon that works across both light and dark themes.", | 		description: "Improve or update an existing standard icon.", | ||||||
| 		url: `${REPO_PATH}/issues/new?template=update_normal_icon.yml`, | 		url: `${REPO_PATH}/issues/new?template=update_normal_icon.yml`, | ||||||
| 	}, | 	}, | ||||||
| 	{ | 	{ | ||||||
| 		id: "blank_issue", | 		id: "blank_issue", | ||||||
| 		name: "Something else", | 		name: "Other request", | ||||||
| 		description: "Create a custom issue for other suggestions, bug reports, or improvements.", | 		description: "Submit another type of request.", | ||||||
| 		url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`, | 		url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`, | ||||||
| 	}, | 	}, | ||||||
| ] | ] | ||||||
| @@ -73,13 +73,13 @@ export function IconSubmissionForm() { | |||||||
| 		<Dialog open={open} onOpenChange={setOpen}> | 		<Dialog open={open} onOpenChange={setOpen}> | ||||||
| 			<DialogTrigger asChild> | 			<DialogTrigger asChild> | ||||||
| 				<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300"> | 				<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> | 				</Button> | ||||||
| 			</DialogTrigger> | 			</DialogTrigger> | ||||||
| 			<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background"> | 			<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background"> | ||||||
| 				<DialogHeader> | 				<DialogHeader> | ||||||
| 					<DialogTitle>Contribute a new icon</DialogTitle> | 					<DialogTitle>Submit an icon</DialogTitle> | ||||||
| 					<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription> | 					<DialogDescription>Select an option below to submit or update an icon.</DialogDescription> | ||||||
| 				</DialogHeader> | 				</DialogHeader> | ||||||
| 				<div className="mt-4"> | 				<div className="mt-4"> | ||||||
| 					<IconSubmissionContent onClose={() => setOpen(false)} /> | 					<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 { motion, useMotionTemplate, useMotionValue } from "motion/react" | ||||||
| import type React from "react" | import type React from "react" | ||||||
| import { useCallback, useEffect, useRef } from "react" | import { useCallback, useEffect, useRef, useState } from "react" | ||||||
|  |  | ||||||
| import { cn } from "@/lib/utils" | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
| @@ -28,6 +28,7 @@ export function MagicCard({ | |||||||
| 	const cardRef = useRef<HTMLDivElement>(null) | 	const cardRef = useRef<HTMLDivElement>(null) | ||||||
| 	const mouseX = useMotionValue(-gradientSize) | 	const mouseX = useMotionValue(-gradientSize) | ||||||
| 	const mouseY = useMotionValue(-gradientSize) | 	const mouseY = useMotionValue(-gradientSize) | ||||||
|  | 	const [isMounted, setIsMounted] = useState(false) | ||||||
|  |  | ||||||
| 	const handleMouseMove = useCallback( | 	const handleMouseMove = useCallback( | ||||||
| 		(e: MouseEvent) => { | 		(e: MouseEvent) => { | ||||||
| @@ -60,6 +61,14 @@ export function MagicCard({ | |||||||
| 	}, [handleMouseMove, mouseX, gradientSize, mouseY]) | 	}, [handleMouseMove, mouseX, gradientSize, mouseY]) | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
|  | 		setIsMounted(true) | ||||||
|  | 		mouseX.set(-gradientSize) | ||||||
|  | 		mouseY.set(-gradientSize) | ||||||
|  | 	}, [gradientSize, mouseX, mouseY]) | ||||||
|  |  | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (!isMounted) return | ||||||
|  |  | ||||||
| 		document.addEventListener("mousemove", handleMouseMove) | 		document.addEventListener("mousemove", handleMouseMove) | ||||||
| 		document.addEventListener("mouseout", handleMouseOut) | 		document.addEventListener("mouseout", handleMouseOut) | ||||||
| 		document.addEventListener("mouseenter", handleMouseEnter) | 		document.addEventListener("mouseenter", handleMouseEnter) | ||||||
| @@ -69,15 +78,10 @@ export function MagicCard({ | |||||||
| 			document.removeEventListener("mouseout", handleMouseOut) | 			document.removeEventListener("mouseout", handleMouseOut) | ||||||
| 			document.removeEventListener("mouseenter", handleMouseEnter) | 			document.removeEventListener("mouseenter", handleMouseEnter) | ||||||
| 		} | 		} | ||||||
| 	}, [handleMouseEnter, handleMouseMove, handleMouseOut]) | 	}, [isMounted, handleMouseEnter, handleMouseMove, handleMouseOut]) | ||||||
|  |  | ||||||
| 	useEffect(() => { |  | ||||||
| 		mouseX.set(-gradientSize) |  | ||||||
| 		mouseY.set(-gradientSize) |  | ||||||
| 	}, [gradientSize, mouseX, mouseY]) |  | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}> | 		<div className={cn("group relative rounded-[inherit]", className)}> | ||||||
| 			<motion.div | 			<motion.div | ||||||
| 				className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100" | 				className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100" | ||||||
| 				style={{ | 				style={{ | ||||||
| @@ -100,7 +104,7 @@ export function MagicCard({ | |||||||
| 					opacity: gradientOpacity, | 					opacity: gradientOpacity, | ||||||
| 				}} | 				}} | ||||||
| 			/> | 			/> | ||||||
| 			<div className="relative">{children}</div> | 			<div ref={cardRef} className="relative">{children}</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	) | 	) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) { | |||||||
| 			{/* Background glow */} | 			{/* 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="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"> | 				<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"> | 					<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 | 						Recently Added Icons | ||||||
| @@ -61,7 +61,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) { | |||||||
| 						href="/icons" | 						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" | 						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" /> | 						<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" /> | ||||||
| 					</Link> | 					</Link> | ||||||
| 				</div> | 				</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 WEB_URL = "https://dashboardicons.com" | ||||||
| export const REPO_NAME = "homarr-labs/dashboard-icons" | export const REPO_NAME = "homarr-labs/dashboard-icons" | ||||||
|  |  | ||||||
| export const getDescription = (totalIcons: number) => | // Site-wide metadata constants | ||||||
| 	`A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.` | 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