Merge branch 'main' into feat/ai-categories

This commit is contained in:
Thomas Camlong 2025-04-18 22:39:01 +02:00 committed by GitHub
commit 39620c27b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 4247 additions and 3410 deletions

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# This condition ensures the job only runs when the 'approved' label is added and the issue title starts with 'feat(icons): add ' # This condition ensures the job only runs when the 'approved' label is added and the issue title starts with 'feat(icons): add '
if: | if: |
contains(github.event.issue.labels.*.name, 'approved') && contains(github.event.issue.labels.*.name, 'approved') &&
startsWith(github.event.issue.title, 'feat(icons): add ') startsWith(github.event.issue.title, 'feat(icons): add ')
env: env:
ICON_TYPE: ${{ contains(github.event.issue.labels.*.name, 'normal-icon') && 'normal' || 'monochrome' }} ICON_TYPE: ${{ contains(github.event.issue.labels.*.name, 'normal-icon') && 'normal' || 'monochrome' }}
@ -49,8 +49,6 @@ jobs:
INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }} INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }}
- name: Generate File Tree - name: Generate File Tree
run: python scripts/generate_file_tree.py svg png webp run: python scripts/generate_file_tree.py svg png webp
- name: Generate ICONS.md
run: python scripts/generate_icons_page.py
- name: Generate full metadata file - name: Generate full metadata file
run: python scripts/generate_metadata.py run: python scripts/generate_metadata.py
- name: Extract icon name - name: Extract icon name

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# This condition ensures the job only runs when the 'approved' label is updated and the issue title starts with 'feat(icons): update ' # This condition ensures the job only runs when the 'approved' label is updated and the issue title starts with 'feat(icons): update '
if: | if: |
contains(github.event.issue.labels.*.name, 'approved') && contains(github.event.issue.labels.*.name, 'approved') &&
startsWith(github.event.issue.title, 'feat(icons): update ') startsWith(github.event.issue.title, 'feat(icons): update ')
env: env:
ICON_TYPE: ${{ contains(github.event.issue.labels.*.name, 'normal-icon') && 'normal' || 'monochrome' }} ICON_TYPE: ${{ contains(github.event.issue.labels.*.name, 'normal-icon') && 'normal' || 'monochrome' }}
@ -49,8 +49,6 @@ jobs:
INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }} INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }}
- name: Generate File Tree - name: Generate File Tree
run: python scripts/generate_file_tree.py svg png webp run: python scripts/generate_file_tree.py svg png webp
- name: Generate ICONS.md
run: python scripts/generate_icons_page.py
- name: Generate full metadata file - name: Generate full metadata file
run: python scripts/generate_metadata.py run: python scripts/generate_metadata.py
- name: Extract icon name - name: Extract icon name

View File

@ -77,14 +77,9 @@ jobs:
with: with:
python-version: "3.9" python-version: "3.9"
- name: Generate ICONS.md
run: python scripts/generate_icons_page.py
- name: Commit and Push Changes - name: Commit and Push Changes
run: | run: |
git config --global user.email "homarr-labs@proton.me" git config --global user.email "homarr-labs@proton.me"
git config --global user.name "Dashboard Icons Bot" git config --global user.name "Dashboard Icons Bot"
git add ICONS.md
git commit -m "ci(github-actions): generate ICONS.md" || exit 0
git pull --rebase origin ${{ github.ref_name }} git pull --rebase origin ${{ github.ref_name }}
git push origin HEAD:${{ github.ref_name }} git push origin HEAD:${{ github.ref_name }}

View File

@ -1,30 +1,42 @@
## Code of Conduct # Code of Conduct
We are committed to creating a welcoming and harassment-free environment for everyone who contributes to our icon repository. This includes people of all genders, gender identities, sexual orientations, disabilities, appearances, body sizes, races, ages, religions, and nationalities. ## Our Commitment
### Communication We are committed to maintaining a welcoming and inclusive environment for everyone who contributes to our icon collection. This includes people of all backgrounds, identities, and experiences.
All communication should be appropriate for a professional audience, respectful, constructive, and considerate of people from different backgrounds. Please aim to create a positive and inclusive atmosphere. ## Expected Behavior
### Prohibited Behavior - Be respectful and constructive in all communications
- Focus on what's best for the community
- Show empathy towards other community members
- Be open to different viewpoints and experiences
We do not tolerate harassment, intimidation, discrimination, or any other inappropriate conduct, whether in communication or behavior. Prohibited actions include: ## Unacceptable Behavior
- The use of sexual language or imagery The following behaviors are unacceptable:
- Deliberate intimidation or stalking
- Unwelcome sexual attention or harassment
- Inappropriate physical contact
- Disruptions during events or conversations
- Discrimination of any kind
### Reporting - Harassment, discrimination, or intimidation
- Offensive comments related to personal characteristics
- Unwelcome sexual attention or advances
- Disruptive behavior in community spaces
- Any other conduct that could reasonably be considered inappropriate
If you witness or experience behavior that violates this code of conduct, please report it immediately to [homarr-labs@proton.me](mailto:homarr-labs@proton.me). All reports will be reviewed confidentially and promptly, and appropriate actions will be taken. ## Reporting
### Consequences If you experience or witness behavior that violates this code:
Anyone violating this code of conduct may face consequences, such as warnings, removal from the repository, or a ban from future participation. We take violations seriously to ensure a safe and welcoming environment for everyone. 1. Contact us at [homarr-labs@proton.me](mailto:homarr-labs@proton.me)
2. Provide as much detail as possible about the incident
3. All reports will be reviewed confidentially
### Acknowledgment ## Enforcement
By contributing to this repository, you agree to adhere to this code of conduct. Thank you for helping us create an inclusive and supportive environment for all contributors. Violations of this code may result in:
- Warning
- Temporary suspension
- Permanent ban from the community
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.0.

View File

@ -1,104 +1,104 @@
![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen?style=flat-square) # Contributing to Dashboard Icons
## Contribution Guidelines Thank you for your interest in contributing to our icon collection! These guidelines will help ensure smooth collaboration and maintain the quality of our collection.
Thank you for your interest in contributing to the icon repository! To ensure smooth collaboration, please follow these guidelines. Your contributions help make this project better.
## Table of Contents ## Table of Contents
- [Contribution Guidelines](#contribution-guidelines) - [Contributing to Dashboard Icons](#contributing-to-dashboard-icons)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Icon Specifications](#icon-specifications) - [Icon Specifications](#icon-specifications)
- [Format](#format) - [Format Requirements](#format-requirements)
- [Cropping](#cropping) - [Quality Standards](#quality-standards)
- [Light and Dark Versions](#light-and-dark-versions) - [Light \& Dark Variants](#light--dark-variants)
- [File Naming](#file-naming) - [File Naming](#file-naming)
- [Quality Requirements](#quality-requirements) - [Requesting New Icons](#requesting-new-icons)
- [Git Commit Messages](#git-commit-messages) - [Improving the Repository](#improving-the-repository)
- [Contribution Process](#contribution-process) - [Code of Conduct](#code-of-conduct)
- [Code of Conduct](#code-of-conduct) - [Questions?](#questions)
- [Contact](#contact)
## Icon Specifications ## Icon Specifications
### Format ### Format Requirements
- **SVG Format Required**: All icons should be submitted in SVG format. If an SVG version is unavailable, a PNG version will suffice, and a WEBP version will be generated accordingly. - **SVG Format**: All icons must be submitted in SVG format
- **Automatic PNG and WEBP Generation**: PNG and WEBP versions are generated automatically from the SVG (or PNG) files using the following settings: - **Auto-Generated Formats**: PNG and WEBP versions are generated automatically with:
- **Dimensions**: - Height: 512 pixels
- Height: 512 pixels - Width: Auto (maintaining aspect ratio)
- Width: Auto (maintaining aspect ratio) - Transparency: Enabled
- **Transparency**: Enabled
### Cropping ### Quality Standards
- **Remove Empty Space**: Crop any empty space from your SVG files to ensure the icon is properly centered and sized. You can use [SVG Crop](https://svgcrop.com/) to assist with this. - **Clean SVG**: No embedded raster images in SVG files
- **Proper Cropping**: Remove empty space for proper centering
- Use [SVG Crop](https://svgcrop.com/) for assistance
- **No Upscaling**: Maintain original quality without artificial enlargement
### Light and Dark Versions ### Light & Dark Variants
- **Monochrome or Single Primary Color Icons**: For monochrome or single-color icons:
- If your icon is monochrome, please provide additional versions if applicable:
- **`-light` Version**: For icons primarily dark or using black as a main color, provide a `-light` version for light backgrounds. - **Light Variant**: Required for dark backgrounds
- **`-dark` Version**: For icons primarily light or using white as a main color, provide a `-dark` version for dark backgrounds. - Invert black elements
- **Examples**: - Adjust colors for visibility
- A black logo should include a `-light` version where black is inverted. - **Dark Variant**: Required for light backgrounds
- A multicolored logo using black should provide a `-light` version with the black replaced. - Invert white elements
- **Tool Recommendation**: [DEEditor](https://deeditor.com/) can help adjust icon colors if needed. - Adjust colors for visibility
**Tool Recommendation**: [DEEditor](https://deeditor.com/) for color adjustments
### File Naming ### File Naming
- **Kebab Case**: Name your files using kebab case (lowercase words separated by hyphens). For example, "Nextcloud Calendar" becomes `nextcloud-calendar.svg`. - **Kebab Case**: Use lowercase with hyphens
- **Note**: Filenames are automatically converted to kebab case, but please double-check your naming to avoid conflicts or errors. - Example: "Nextcloud Calendar" → `nextcloud-calendar.svg`
- **Variant Suffixes**:
- `-light` for dark backgrounds
- `-dark` for light backgrounds
### Quality Requirements ## Requesting New Icons
- **No Upscaled Images**: Icons should maintain their original quality without artificial enlargement. To request a new icon:
- **No Embedded Raster Images in SVGs**: Ensure that SVG files are true vector graphics without embedded raster images.
## Git Commit Messages 1. **Create an Issue**:
- Use the appropriate [issue template](https://github.com/homarr-labs/dashboard-icons/issues/new/choose)
- Choose between "Light & dark icon" or "Normal icon" template
- **Use Semantic Commits**: Follow the format <type>(scope): description: 2. **Provide Information**:
- `feat(icons): add nextcloud-calendar` when adding new icons. - Service/application name
- Official logo or icon source
- Any specific requirements or notes
## Contribution Process 3. **Upload Icon** (optional):
- Attach the SVG file directly to the issue
- Include both light and dark variants if applicable
### Adding an icon 4. **Wait for Review**:
- Our team will review your request
- We may request adjustments if needed
- Once approved, we'll add the icon to the collection
To add an icon to the repository, follow these steps: ## Improving the Repository
1. **Create issue**: Create an issue from one of the add [templates](https://github.com/homarr-labs/dashboard-icons/issues/new/choose): To contribute to the repository itself:
- **Light & dark icon**: Use this template to request a new icon with both light and dark versions.
- **Normal icon**: Use this template to request a new icon with a single version.
2. **Fill out the template**: Provide the requested information in the template. You can upload the icons directly to the issue.
3. **Wait for approval**: Wait for the issue to be approved by a maintainer. If any changes are needed, they will be requested in the issue.
4. **Maintainer approves & merges**: Once the issue is approved, a pull request with all the necessary changes will be created and merged by a maintainer.
### Updating an icon 1. **Fork the Repository**
2. **Make Your Changes**:
- Documentation improvements
- Website enhancements
- Repository maintenance
- Bug fixes
To update an icon in the repository, follow these steps: 3. **Submit a Pull Request**:
- Use semantic commit messages following the format: `<type>(scope): description`
1. **Create issue**: Create an issue from the update [template](https://github.com/homarr-labs/dashboard-icons/issues/new/choose). - `feat(icons): add nextcloud-calendar`
- **Light & dark icon**: Use this template to request a new icon with both light and dark versions. - `fix(website): correct icon preview`
- **Normal icon**: Use this template to request a new icon with a single version. - `docs(readme): update installation instructions`
2. **Fill out the template**: Provide the requested information in the template. You can upload the icons directly to the issue. - Reference any related issues
3. **Wait for approval**: Wait for the issue to be approved by a maintainer. If any changes are needed, they will be requested in the issue. - Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
4. **Maintainer approves & merges**: Once the issue is approved, a pull request with all the necessary changes will be created and merged by a maintainer.
### Change metadata / any other change
To change the metadata of an existing icon or any other change, follow these steps:
1. **Fork the Repository**: Create a fork of this repository on your GitHub account.
2. **Clone the Repository**: Clone your forked repository to your local machine.
3. **Add Your Icons**: Place your SVG icon(s) into the appropriate directory, following the specifications above.
4. **Commit Your Changes**: Commit your additions with clear and descriptive commit messages using Gitmoji.
5. **Push to Your Fork**: Push your committed changes to your forked repository on GitHub.
6. **Create a Pull Request**: Submit a pull request to the main repository for review.
## Code of Conduct ## Code of Conduct
By contributing, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it to understand the expectations for all participants. By contributing, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it to understand the expectations for all participants.
## Contact ## Questions?
If you have any questions or need assistance, feel free to reach out at [homarr-labs@proton.me](mailto:homarr-labs@proton.me). I'm happy to help. If you have any questions or need assistance, contact us at [homarr-labs@proton.me](mailto:homarr-labs@proton.me).

2228
ICONS.md

File diff suppressed because it is too large Load Diff

184
README.md
View File

@ -1,116 +1,126 @@
> [!WARNING] # Dashboard Icons
> The repository has been migrated from `walkxcode` to `homarr-labs` as I no longer have the capacity to maintain it. The Homarr team will now handle management and maintenance, ensuring that functionality remains unchanged. The project will always be usable outside of Homarr and no breaking changes will be introduced.
> ― *Bjorn*
>
> The license and guidelines have been updated, so please review them. To help with maintenance, contact us at [homarr-labs@proton.me](mailto:homarr-labs@proton.me).
[![jsDelivr hits (GitHub)](https://img.shields.io/jsdelivr/gh/hy/homarr-labs/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons) [![jsDelivr hits](https://img.shields.io/jsdelivr/gh/hy/walkxcode/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/walkxcode/dashboard-icons)
[![jsDelivr hits (GitHub)](https://img.shields.io/jsdelivr/gh/hy/walkxcode/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/walkxcode/dashboard-icons) [![jsDelivr hits](https://img.shields.io/jsdelivr/gh/hy/homarr-labs/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons)
[![GitHub Stars](https://img.shields.io/github/stars/homarr-labs/dashboard-icons?style=flat-square&color=yellow)](https://github.com/homarr-labs/dashboard-icons/stargazers)
[![Contributors](https://img.shields.io/github/contributors/homarr-labs/dashboard-icons?style=flat-square&color=blue)](https://github.com/homarr-labs/dashboard-icons/graphs/contributors)
[https://icons.homarr.dev](https://icons.homarr.dev) > **Your definitive source for dashboard icons.**
## Dashboard Icons A collection of over 1800 curated icons for services, applications and tools, designed specifically for dashboards and app directories.
Your definitive source for dashboard icons. **[→ Browse the collection at dashboardicons.com](https://dashboardicons.com)**
[**View icons →**](https://icons.homarr.dev)
## Table of Contents ## Why Dashboard Icons?
- [Dashboard Icons](#dashboard-icons) - **Comprehensive Collection**: 1800+ icons for all popular services and tools
- [Table of Contents](#table-of-contents) - **Consistent Style**: Uniform visual language across different services
- [Icon Requests](#icon-requests) - **Multiple Formats**: Available in SVG, PNG, and WEBP to suit your needs
- [Supported Dashboards](#supported-dashboards) - **Light & Dark Variants**: Icons optimized for both light and dark themes
- [Usage and Details](#usage-and-details) - **Community-Driven**: Easy process to request missing icons
- [Direct Links](#direct-links)
- [Base URL](#base-url)
- [Icon Name](#icon-name)
- [Formats](#formats)
- [Dark/Light Variants](#darklight-variants)
- [Downloading Icons](#downloading-icons)
- [Disclaimer](#disclaimer)
## Icon Requests <p align="center">
<a href="https://dashboardicons.com">
<video width="650" autoplay loop muted playsinline>
<source src="assets/preview.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</a>
</p>
If youd like to add a new icon, please review our [Contribution Guidelines](CONTRIBUTING.md) and then submit a request using [our issue templates](https://github.com/homarr-labs/dashboard-icons/issues/new/choose). ## Using the Icons
## Supported Dashboards ### Website
Dashboard Icons integrate seamlessly with several popular dashboards, including: Find and download icons at [dashboardicons.com](https://dashboardicons.com):
1. Search for the icon you need
2. Click on an icon to view details
3. Choose your preferred format
4. Download or copy the direct link
### Direct Links
Use icons from CDN with this pattern:
```
<Base URL>/<Format>/<Icon Name>.<Format>
```
**Base URL options:**
- jsDelivr (recommended): `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons`
- GitHub Direct: `https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main`
**Example:**
```html
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg" alt="Plex">
```
### Technical Details
- **Naming Convention**: Kebab-case (lowercase with hyphens)
- Example: "Nextcloud Calendar" → `nextcloud-calendar`
- **Available Formats**:
- SVG: Vector format (original source)
- PNG: 512px height (auto-generated)
- WEBP: 512px height (auto-generated)
- **Variants**:
- `-light` suffix for dark backgrounds (e.g., `github-light.svg`)
- `-dark` suffix for light backgrounds (e.g., `github-dark.svg`)
- **Command Line**:
```bash
# Download with curl
curl -O https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg
# Download with wget
wget https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg
```
## Dashboard Integration
These icons integrate seamlessly with popular dashboard applications:
- [Homarr](https://github.com/ajnart/homarr) - [Homarr](https://github.com/ajnart/homarr)
- [Homepage](https://github.com/gethomepage/homepage) - [Homepage](https://github.com/gethomepage/homepage)
- [Dashy](https://github.com/Lissy93/dashy) - [Dashy](https://github.com/Lissy93/dashy)
## Usage and Details ...and many others!
### Direct Links ## Contributing
You can use icons directly from GitHub or through the lightning-fast jsDelivr CDN. The structure of a direct link is as follows: ### Request Icons
``` Need an icon that's not in our collection?
https://<Base URL>/<Format>/<Name>.<Format>
```
For example, the WEBP version of the Nextcloud Calendar icon is available at: 1. Check the [Contribution Guidelines](CONTRIBUTING.md) for specifications
2. Submit a request using our [issue templates](https://github.com/homarr-labs/dashboard-icons/issues/new/choose)
3. Provide service details and optionally upload the icon
4. Our team will review, optimize, and add it to the collection
``` ### Improve the Repository
https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/nextcloud-calendar.webp
```
#### Base URL Want to help with the repository itself?
We recommend using jsDelivr: - Review our [Contribution Guidelines](CONTRIBUTING.md)
- Fork the repository, make your changes, and submit a pull request
- We welcome help with documentation, website improvements, and maintenance
- `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons` ## Support
Alternatively, you can reference the repository directly: - **GitHub Issues**: Report bugs or request icons
- **Email**: [homarr-labs@proton.me](mailto:homarr-labs@proton.me)
- `https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main` ## Legal
#### Icon Name **Disclaimer**: All product names, trademarks, and registered trademarks are the property of their respective owners. Icons are used for identification purposes only and do not imply endorsement.
Icons follow kebab-case formatting (all lowercase words separated by hyphens). For example, "Nextcloud Calendar" becomes `nextcloud-calendar`. **License**: This project is available under the terms of the [LICENSE](LICENSE) file.
#### Formats ---
Icons are available in these formats: <p align="center">
Made with ♥ by the <a href="https://github.com/homarr-labs">Homarr Labs</a> team and contributors
- SVG </p>
- PNG
- WEBP
*All icons are generated from the base SVG file. For more details, see the [Contribution Guidelines](CONTRIBUTING.md).*
### Dark/Light Variants
Some icons may have very light or dark colors, which might reduce visibility on certain backgrounds. In such cases, a `-light` or `-dark` suffix is appended—for instance, "2fauth" becomes `2fauth-light`.
*More specifics are available in the [Contribution Guidelines](CONTRIBUTING.md).*
### Downloading Icons
1. **Browse & Download:**
Visit [https://icons.homarr.dev](https://icons.homarr.dev) to easily view and download icons.
2. **Using the Browser:**
On the icons page ([ICONS.md](ICONS.md)), right-click any icon link and select "Save link as".
**Note:** Loading the icons page displays every icon in the repository, which may lead to high data usage, slow performance, or even browser crashes on less powerful devices. For faster access, use the direct links or download icons individually.
3. **Using the Terminal:**
Download icons via `curl` or `wget` by using the following structure:
```bash
curl -O https://<Base URL>/<Format>/<Name>.<Format>
```
or
```bash
wget https://<Base URL>/<Format>/<Name>.<Format>
```
## Disclaimer
Unless stated otherwise, all images and assets in this repository—including product names, trademarks, and registered trademarks—belong to their respective owners and are used solely for identification purposes. Their inclusion does not imply endorsement.
For more details, please review the [LICENSE](LICENSE). If you have any questions or concerns, contact us at [homarr-labs@proton.me](mailto:homarr-labs@proton.me).

BIN
assets/preview.mp4 Normal file

Binary file not shown.

View File

@ -1,11 +0,0 @@
[![jsDelivr hits (GitHub)](https://img.shields.io/jsdelivr/gh/hy/homarr-labs/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons)
[![jsDelivr hits (GitHub)](https://img.shields.io/jsdelivr/gh/hy/walkxcode/dashboard-icons?style=flat-square&color=%23A020F0)](https://www.jsdelivr.com/package/gh/walkxcode/dashboard-icons)
## Dashboard Icons
The best source for dashboard icons.<br />
[**← Back to repository**](https://github.com/homarr-labs/dashboard-icons/)
<!-- ICONS -->
<!-- END ICONS -->

View File

@ -1,85 +0,0 @@
import json
import pathlib
from pathlib import Path
import sys
# Read the JSON file
def read_tree_json(file_path):
with open(file_path, 'r', encoding='UTF-8') as f:
return json.load(f)
# Generate a table row with checkmarks for available formats and links
def generate_table_row(icon_name, formats):
# Prepare the checkmarks and links for each format if they exist
webp_check = '' if formats['webp'] else ''
png_check = '' if formats['png'] else ''
svg_check = '' if formats['svg'] else ''
# Prepare the links for each format if they exist
webp_link = f'<a href="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/{icon_name}.webp">WebP</a>' if formats['webp'] else 'WebP'
png_link = f'<a href="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/{icon_name}.png">PNG</a>' if formats['png'] else 'PNG'
svg_link = f'<a href="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/{icon_name}.svg">SVG</a>' if formats['svg'] else 'SVG'
# Combine checkmarks and links (or just name if not available)
webp_info = f'{webp_check} {webp_link}' if formats['webp'] else f'{webp_check} {webp_link}'
png_info = f'{png_check} {png_link}' if formats['png'] else f'{png_check} {png_link}'
svg_info = f'{svg_check} {svg_link}' if formats['svg'] else f'{svg_check} {svg_link}'
# Generate preview using WebP
preview = f'<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/{icon_name}.webp" height="50" alt="{icon_name}">'
return f"| {icon_name} | {webp_info} {png_info} {svg_info} | {preview} |"
if __name__ == "__main__":
# Define paths
root = pathlib.Path(__file__).parent.resolve()
tree_json_path = root.parent / "tree.json"
template_path = root / "TEMPLATE.md"
icons_md_path = root.parent / "ICONS.md"
# Load the tree.json data
formats = read_tree_json(tree_json_path)
# Create a dictionary to hold icons by their base name (ignoring file extensions)
icons_dict = {}
# Check the formats and group icons by their base name
for ext, icons in formats.items():
for icon in icons:
base_name = icon.rsplit('.', 1)[0] # Get base name (without extension)
if base_name not in icons_dict:
icons_dict[base_name] = {'webp': False, 'png': False, 'svg': False}
icons_dict[base_name][ext] = True
# Create table for all icons (unique names)
table_rows = []
for icon_name in sorted(icons_dict.keys()):
table_row = generate_table_row(icon_name, icons_dict[icon_name])
table_rows.append(table_row)
# Prepare the table with header and rows
table_header = "| Name | Links | Preview |"
table_separator = "|------|-------|---------|"
table_content = "\n".join(table_rows)
table = f"{table_header}\n{table_separator}\n{table_content}"
# Read the template file
with open(template_path, "r", encoding="UTF-8") as f:
template = f.read()
# Find the line that starts with "<!-- ICONS -->"
try:
line_number = template.index("<!-- ICONS -->")
except ValueError:
print("<!-- ICONS --> placeholder not found in TEMPLATE.md")
sys.exit(1)
# Insert the table after the placeholder
updated_template = template[:line_number] + "<!-- ICONS -->\n" + table + template[line_number + len("<!-- ICONS -->"):]
# Write the new ICONS.md file
with open(icons_md_path, "w", encoding="UTF-8") as f:
f.write(updated_template)
print("ICONS.md has been successfully generated.")

View File

@ -19,7 +19,10 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"suspicious": {
"noArrayIndexKey": "off"
}
} }
}, },
"javascript": { "javascript": {

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "pnpx serve@latest out",
"format": "biome check --write", "format": "biome check --write",
"lint": "biome lint --write", "lint": "biome lint --write",
"ci": "biome check --write" "ci": "biome check --write"
@ -39,15 +39,16 @@
"@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-virtual": "^3.13.6", "@tanstack/react-virtual": "^3.13.6",
"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", "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.6.5", "framer-motion": "^12.7.3",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"motion": "^12.6.5", "motion": "^12.7.3",
"next": "15.3.0", "next": "15.3.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"posthog-js": "^1.235.6", "posthog-js": "^1.235.6",
@ -60,6 +61,7 @@
"recharts": "^2.15.2", "recharts": "^2.15.2",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss-motion": "^1.1.0",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.24.2" "zod": "^3.24.2"
@ -67,14 +69,22 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.1.3", "@tailwindcss/postcss": "^4.1.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.3", "tailwindcss": "^4.1.3",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"wrangler": "^4.12.0"
}, },
"packageManager": "pnpm@10.8.0", "packageManager": "pnpm@10.8.0",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": ["@biomejs/biome", "sharp"] "onlyBuiltDependencies": [
"@biomejs/biome",
"core-js",
"esbuild",
"sharp",
"workerd"
]
} }
} }

885
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 82.9 512 346.2"><path d="M169.3 102.7c28.8 0 52.2 23.4 52.2 52.2v66.8c1.8-.6 3.6-1 5.5-1 5.9 0 11.1 2.9 14.2 7.4v-73.2c0-39.7-32.3-72-72-72-24.8 0-46.8 12.6-59.7 31.8 5.6 3.6 10.9 7.5 16 11.8 9.4-14.3 25.5-23.8 43.8-23.8m115.6 118.1c1.9 0 3.8.4 5.5 1V155c0-28.8 23.4-52.2 52.2-52.2 18.3 0 34.4 9.5 43.8 23.8 5.1-4.2 10.4-8.2 16-11.8C389.5 95.6 367.5 83 342.7 83c-39.7 0-72 32.3-72 72v73.2c3.1-4.5 8.3-7.4 14.2-7.4m-69.3 104.8c-35.7 8.6-66 28.1-88.1 54-12.2 14.3-21.9 30.6-28.6 48l46.6.3 265.5 1.2v-.1c-29.2-77.8-112.5-123.4-195.4-103.4m11.5-61.9c-9.7 0-17.5 7.8-17.5 17.5s7.8 17.5 17.5 17.5 17.5-7.8 17.5-17.5-7.8-17.5-17.5-17.5m57.8 35.1c9.7 0 17.5-7.8 17.5-17.5s-7.8-17.5-17.5-17.5-17.5 7.8-17.5 17.5 7.8 17.5 17.5 17.5m162.5-75.6 26.7-108.8c-22.6 2.8-43.5 11.1-61.5 23.4-6.3 4.3-12.3 9.2-17.8 14.4-26.4 25.4-42.9 61.1-42.9 100.6 0 30.7 9.9 59.1 26.7 82.1 9.2 12.7 20.6 23.7 33.4 32.6l9.3-37.8c47.9-14.3 84.1-55.7 90.7-106.5zM133.5 334.9c16.8-23 26.7-51.4 26.7-82.1 0-39.5-16.5-75.2-42.9-100.6-5.5-5.3-11.5-10.1-17.8-14.4-17.9-12.3-38.8-20.6-61.5-23.4l26.7 108.8H0c6.6 50.8 42.8 92.2 90.7 106.5l9.3 37.8c12.9-8.9 24.2-19.9 33.5-32.6" style="fill:#fa5252"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,26 @@
{
"name": "Dashboard Icons",
"short_name": "DashIcons",
"description": "A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"theme_color": "#FA5252",
"background_color": "#1B1B1D",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"categories": ["tools", "utilities", "productivity"]
}

51
web/src/app/error.tsx Normal file
View File

@ -0,0 +1,51 @@
"use client"
import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft, RefreshCcw } from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const router = useRouter()
useEffect(() => {
// Log the error to an error reporting service
console.error("Application error:", error)
}, [error])
const handleGoBack = () => {
router.back()
}
return (
<div className="py-16 flex items-center justify-center">
<div className="text-center space-y-6 max-w-md">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
<AlertTriangle className="w-8 h-8" />
</div>
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground">
An unexpected error occurred while loading this page. We've been notified and are looking into it.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
<Button variant="outline" onClick={() => reset()} className="cursor-pointer">
<RefreshCcw className="mr-2 h-4 w-4" />
Try again
</Button>
<Button onClick={handleGoBack} className="cursor-pointer">
<ArrowLeft className="mr-2 h-4 w-4" />
Go back
</Button>
</div>
{error.digest && <p className="text-xs text-muted-foreground mt-6">Error ID: {error.digest}</p>}
</div>
</div>
)
}

View File

@ -1,48 +1,64 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "tailwindcss-motion";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes accordion-down { @keyframes accordion-down {
from { from {
@ -61,28 +77,85 @@
height: 0; height: 0;
} }
} }
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
@keyframes aurora {
0% {
background-position: 0% 50%;
transform: rotate(-5deg) scale(0.9);
}
25% {
background-position: 50% 100%;
transform: rotate(5deg) scale(1.1);
}
50% {
background-position: 100% 50%;
transform: rotate(-3deg) scale(0.95);
}
75% {
background-position: 50% 0%;
transform: rotate(3deg) scale(1.05);
}
100% {
background-position: 0% 50%;
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 {
--radius: 0.3rem; --radius: 0.4rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --background: oklch(0.99 0 0);
--card: oklch(1 0 0); --foreground: oklch(0.32 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card: oklch(1.0 0 0);
--popover: oklch(1 0 0); --card-foreground: oklch(0.32 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1.0 0 0);
--primary: oklch(0.637 0.237 25.331); --popover-foreground: oklch(0.32 0 0);
--primary-foreground: oklch(0.971 0.013 17.38); --primary: oklch(0.67 0.2 23.8);
--secondary: oklch(0.967 0.001 286.375); --primary-foreground: oklch(1.0 0 0);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary: oklch(0.97 0.0 264.54);
--muted: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.45 0.03 256.8);
--muted-foreground: oklch(0.552 0.016 285.938); --muted: oklch(0.98 0.0 247.84);
--muted-foreground: oklch(0.55 0.02 264.36);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.64 0.21 25.33);
--border: oklch(0.92 0.004 286.32); --destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.9 0.01 247.88);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@ -96,25 +169,41 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.637 0.237 25.331); --sidebar-ring: oklch(0.637 0.237 25.331);
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.25);
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.92 0 0);
--card: oklch(0.21 0.006 285.885); --card: oklch(0.31 0.03 268.64);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.92 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.29 0.02 268.4);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.92 0 0);
--primary: oklch(0.637 0.237 25.331); --primary: oklch(0.67 0.2 23.8);
--primary-foreground: oklch(0.971 0.013 17.38); --primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.31 0.03 266.71);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.31 0.03 266.71);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.34 0.06 267.59);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.88 0.06 254.13);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.64 0.21 25.33);
--border: oklch(1 0 0 / 10%); --destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.38 0.03 269.73);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.637 0.237 25.331); --ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
@ -130,6 +219,20 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.637 0.237 25.331); --sidebar-ring: oklch(0.637 0.237 25.331);
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.25);
} }
@layer base { @layer base {
@ -140,3 +243,21 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@layer utilities {
.hover-lift {
@apply transition-transform duration-300 hover:-translate-y-1;
}
.soft-shadow {
@apply shadow-[0_8px_30px_rgba(0,0,0,0.06)];
}
.card-hover {
@apply transition-all duration-300 hover:shadow-md;
}
.glass-effect {
@apply backdrop-blur-sm;
}
}

View File

@ -0,0 +1,254 @@
import { getAllIcons } from "@/lib/api"
import { ImageResponse } from "next/og"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
export const dynamic = "force-static"
export async function generateStaticParams() {
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({
icon,
}))
}
export const size = {
width: 1200,
height: 630,
}
export default async function Image({ params }: { params: { icon: string } }) {
const { icon } = params
const iconsData = await getAllIcons()
const totalIcons = Object.keys(iconsData).length
const index = Object.keys(iconsData).indexOf(icon)
// Format the icon name for display
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
// Read the icon file from local filesystem
let iconData: Buffer | null = null
try {
const iconPath = join(process.cwd(), `../png/${icon}.png`)
console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`)
iconData = await readFile(iconPath)
} catch (error) {
console.error(`Icon ${icon} was not found locally`)
}
// Convert the image data to a data URL or use placeholder
const iconUrl = iconData
? `data:image/png;base64,${iconData.toString("base64")}`
: `https://placehold.co/600x400?text=${formattedIconName}`
return new ImageResponse(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
fontFamily: "Inter, system-ui, sans-serif",
overflow: "hidden",
backgroundColor: "white",
backgroundImage:
"radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)",
backgroundSize: "100px 100px",
}}
>
{/* Background blur blobs */}
<div
style={{
position: "absolute",
top: -100,
left: -100,
width: 400,
height: 400,
borderRadius: "50%",
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)",
filter: "blur(80px)",
zIndex: 2,
}}
/>
<div
style={{
position: "absolute",
bottom: -150,
right: -150,
width: 500,
height: 500,
borderRadius: "50%",
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(234, 88, 12, 0.1) 100%)",
filter: "blur(100px)",
zIndex: 2,
}}
/>
{/* Main content layout */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
padding: "60px",
gap: "70px",
zIndex: 10,
}}
>
{/* Icon container */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 320,
height: 320,
borderRadius: 32,
background: "white",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)",
padding: 30,
flexShrink: 0,
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
inset: 0,
background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)",
zIndex: 0,
}}
/>
<img
src={iconUrl}
alt={formattedIconName}
width={260}
height={260}
style={{
objectFit: "contain",
position: "relative",
zIndex: 1,
filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1))",
}}
/>
</div>
{/* Text content */}
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 28,
maxWidth: 650,
}}
>
<div
style={{
display: "flex",
fontSize: 64,
fontWeight: 800,
color: "#0f172a",
lineHeight: 1.1,
letterSpacing: "-0.02em",
}}
>
Download {formattedIconName} icon for free
</div>
<div
style={{
display: "flex",
fontSize: 32,
fontWeight: 500,
color: "#64748b",
lineHeight: 1.4,
position: "relative",
paddingLeft: 16,
borderLeft: "4px solid #94a3b8",
}}
>
Amongst {totalIcons} other high-quality dashboard icons
</div>
<div
style={{
display: "flex",
gap: 12,
marginTop: 8,
}}
>
{["SVG", "PNG", "WEBP"].map((format) => (
<div
key={format}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f1f5f9",
color: "#475569",
border: "2px solid #e2e8f0",
borderRadius: 12,
padding: "8px 16px",
fontSize: 18,
fontWeight: 600,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
}}
>
{format}
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 80,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
borderTop: "2px solid rgba(0, 0, 0, 0.05)",
zIndex: 20,
}}
>
<div
style={{
display: "flex",
fontSize: 24,
fontWeight: 600,
color: "#334155",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: "#3b82f6",
marginRight: 4,
}}
/>
dashboardicons.com
</div>
</div>
</div>,
{
...size,
},
)
}

View File

@ -1,5 +1,5 @@
import { IconDetails } from "@/components/icon-details" import { IconDetails } from "@/components/icon-details"
import { BASE_URL } from "@/constants" import { BASE_URL, WEB_URL } 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 { notFound } from "next/navigation" import { notFound } from "next/navigation"
@ -26,20 +26,32 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
if (!iconsData[icon]) { if (!iconsData[icon]) {
notFound() notFound()
} }
const previousImages = (await parent).openGraph?.images || []
const authorData = await getAuthorData(iconsData[icon].update.author.id) const authorData = await getAuthorData(iconsData[icon].update.author.id)
const authorName = authorData.name || authorData.login const authorName = authorData.name || authorData.login
const updateDate = new Date(iconsData[icon].update.timestamp) const updateDate = new Date(iconsData[icon].update.timestamp)
const totalIcons = Object.keys(iconsData).length
console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`) console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`)
const iconImageUrl = `${BASE_URL}/png/${icon}.png` const iconImageUrl = `${BASE_URL}/png/${icon}.png`
const pageUrl = `${BASE_URL}/icons/${icon}` const pageUrl = `${WEB_URL}/icons/${icon}`
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
return { return {
title: `${icon} icon · Dashboard Icons`, title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`, 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.`,
keywords: [`${icon} icon`, "dashboard icon", "free icon", "open source icon", "application icon"], keywords: [
`${formattedIconName} icon`,
"dashboard icon",
"service icon",
"application icon",
"tool icon",
"web dashboard",
"app directory",
],
authors: [ authors: [
{ {
name: "homarr", name: "homarr",
@ -51,30 +63,20 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
}, },
], ],
openGraph: { openGraph: {
title: `${icon} icon · Dashboard Icons`, title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`, description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
type: "article", type: "article",
url: pageUrl, url: pageUrl,
images: [
{
url: iconImageUrl,
width: 512,
height: 512,
alt: `${icon} icon`,
type: "image/png",
},
...previousImages,
],
authors: [authorName, "homarr"], authors: [authorName, "homarr"],
publishedTime: updateDate.toISOString(), publishedTime: updateDate.toISOString(),
modifiedTime: updateDate.toISOString(), modifiedTime: updateDate.toISOString(),
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${icon} icon · Dashboard Icons`, title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`, description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
images: [iconImageUrl], images: [iconImageUrl],
creator: "@ajnavocado", creator: "@homarr_app",
}, },
alternates: { alternates: {
canonical: pageUrl, canonical: pageUrl,
@ -91,9 +93,7 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
notFound() notFound()
} }
// Pass originalIconData directly, assuming IconDetails can handle it
const iconData = originalIconData
const authorData = await getAuthorData(originalIconData.update.author.id) const authorData = await getAuthorData(originalIconData.update.author.id)
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} /> return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
} }

View File

@ -46,11 +46,11 @@ export function IconSearch({ icons, initialQuery = "" }: IconSearchProps) {
return ( return (
<> <>
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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 <Input
type="search" type="search"
placeholder="Search icons by name, aliases, or categories..." placeholder="Search icons by name, aliases, or categories..."
className="w-full pl-8" className="w-full pl-8 transition-all duration-300 text-sm md:text-base"
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
/> />

View File

@ -1,63 +1,144 @@
"use client" "use client"
import { IconSubmissionContent } from "@/components/icon-submission-form" import { IconSubmissionContent } from "@/components/icon-submission-form"
import { MagicCard } from "@/components/magicui/magic-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { BASE_URL } from "@/constants" import { BASE_URL } from "@/constants"
import type { IconSearchProps } from "@/types/icons" import type { Icon, IconSearchProps } from "@/types/icons"
import { Search } from "lucide-react" import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { useTheme } from "next-themes"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
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 initialSort = (searchParams.get("sort") as SortOption) || "relevance"
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "") const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const [filteredIcons, setFilteredIcons] = useState(() => { const { resolvedTheme } = useTheme()
if (!initialQuery?.trim()) return icons
const q = initialQuery.toLowerCase() // Extract all unique categories
return icons.filter(({ name, data }) => { const allCategories = useMemo(() => {
if (name.toLowerCase().includes(q)) return true const categories = new Set<string>()
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true for (const icon of icons) {
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true for (const category of icon.data.categories) {
categories.add(category)
}
}
return Array.from(categories).sort()
}, [icons])
return false // Simple filter function using substring matching
})
})
const filterIcons = useCallback( const filterIcons = useCallback(
(query: string) => { (query: string, categories: string[], sort: SortOption) => {
if (!query.trim()) { // First filter by categories if any are selected
return icons let filtered = icons
if (categories.length > 0) {
filtered = filtered.filter(({ data }) =>
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
)
} }
const q = query.toLowerCase() // Then filter by search query
return icons.filter(({ name, data }) => { if (query.trim()) {
if (name.toLowerCase().includes(q)) return true const q = query.toLowerCase()
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true filtered = filtered.filter(({ name, data }) => {
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true 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
})
}
return false // Apply sorting
}) if (sort === "alphabetical-asc") {
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}
if (sort === "alphabetical-desc") {
return filtered.sort((a, b) => b.name.localeCompare(a.name))
}
if (sort === "newest") {
return filtered.sort((a, b) => {
return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
})
}
// Default sort (relevance or fallback to alphabetical)
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}, },
[icons], [icons],
) )
// Find matched aliases for display purposes
const matchedAliases = useMemo(() => {
if (!searchQuery.trim()) return {}
const q = searchQuery.toLowerCase()
const matches: Record<string, string> = {}
for (const { name, data } of icons) {
// If name doesn't match but an alias does, store the first matching alias
if (!name.toLowerCase().includes(q)) {
const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q))
if (matchingAlias) {
matches[name] = matchingAlias
}
}
}
return matches
}, [icons, searchQuery])
// Use useMemo for filtered icons
const filteredIcons = useMemo(() => {
return filterIcons(searchQuery, selectedCategories, sortOption)
}, [filterIcons, searchQuery, selectedCategories, sortOption])
const updateResults = useCallback( const updateResults = useCallback(
(query: string) => { (query: string, categories: string[], sort: SortOption) => {
setFilteredIcons(filterIcons(query))
const params = new URLSearchParams() const params = new URLSearchParams()
if (query) params.set("q", query) if (query) params.set("q", query)
const newUrl = query ? `${pathname}?${params.toString()}` : pathname // Clear existing category params and add new ones
for (const category of categories) {
params.append("category", category)
}
// Add sort parameter if not default
if (sort !== "relevance" || initialSort !== "relevance") {
params.set("sort", sort)
}
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
router.push(newUrl, { scroll: false }) router.push(newUrl, { scroll: false })
}, },
[filterIcons, pathname, router], [pathname, router, initialSort],
) )
const handleSearch = useCallback( const handleSearch = useCallback(
(query: string) => { (query: string) => {
setSearchQuery(query) setSearchQuery(query)
@ -65,11 +146,45 @@ export function IconSearch({ icons }: IconSearchProps) {
clearTimeout(timeoutRef.current) clearTimeout(timeoutRef.current)
} }
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
updateResults(query) updateResults(query, selectedCategories, sortOption)
}, 100) }, 200) // Changed from 100ms to 200ms
}, },
[updateResults], [updateResults, selectedCategories, sortOption],
) )
const handleCategoryChange = useCallback(
(category: string) => {
let newCategories: string[]
if (selectedCategories.includes(category)) {
// Remove the category if it's already selected
newCategories = selectedCategories.filter((c) => c !== category)
} else {
// Add the category if it's not selected
newCategories = [...selectedCategories, category]
}
setSelectedCategories(newCategories)
updateResults(searchQuery, newCategories, sortOption)
},
[updateResults, searchQuery, selectedCategories, sortOption],
)
const handleSortChange = useCallback(
(sort: SortOption) => {
setSortOption(sort)
updateResults(searchQuery, selectedCategories, sort)
},
[updateResults, searchQuery, selectedCategories],
)
const clearFilters = useCallback(() => {
setSearchQuery("")
setSelectedCategories([])
setSortOption("relevance")
updateResults("", [], "relevance")
}, [updateResults])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timeoutRef.current) { if (timeoutRef.current) {
@ -80,48 +195,258 @@ export function IconSearch({ icons }: IconSearchProps) {
if (!searchParams) return null if (!searchParams) return null
const getSortLabel = (sort: SortOption) => {
switch (sort) {
case "relevance":
return "Best match"
case "alphabetical-asc":
return "A to Z"
case "alphabetical-desc":
return "Z to A"
case "newest":
return "Newest first"
default:
return "Sort"
}
}
const getSortIcon = (sort: SortOption) => {
switch (sort) {
case "relevance":
return <Search className="h-4 w-4" />
case "alphabetical-asc":
return <ArrowDownAZ className="h-4 w-4" />
case "alphabetical-desc":
return <ArrowUpZA className="h-4 w-4" />
case "newest":
return <Calendar className="h-4 w-4" />
default:
return <SortAsc className="h-4 w-4" />
}
}
return ( return (
<> <>
<div className="relative w-full sm:max-w-md"> <div className="space-y-4 w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> {/* Search input */}
<Input <div className="relative w-full">
type="search" <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground transition-all duration-300">
placeholder="Search icons by name, aliases, or categories..." <Search className="h-4 w-4" />
className="w-full pl-8" </div>
value={searchQuery} <Input
onChange={(e) => handleSearch(e.target.value)} type="search"
/> placeholder="Search icons by name, alias, or category..."
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* Filter and sort controls */}
<div className="flex flex-wrap gap-2 justify-start">
{/* Filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm ">
<Filter className="h-4 w-4 mr-2" />
<span>Filter</span>
{selectedCategories.length > 0 && (
<Badge variant="secondary" className="ml-2 px-1.5">
{selectedCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 sm:w-56">
<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[40vh] overflow-y-auto p-1">
{allCategories.map((category) => (
<DropdownMenuCheckboxItem
key={category}
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryChange(category)}
className="cursor-pointer capitalize"
>
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</DropdownMenuCheckboxItem>
))}
</div>
{selectedCategories.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
>
Clear all filters
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Sort dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
{getSortIcon(sortOption)}
<span className="ml-2">{getSortLabel(sortOption)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Best match
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
<ArrowUpZA className="h-4 w-4 mr-2" />Z to A
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
<Calendar className="h-4 w-4 mr-2" />
Newest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Clear all button */}
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
<X className="h-4 w-4 mr-2" />
<span>Clear all</span>
</Button>
)}
</div>
{/* Active filter badges */}
{selectedCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">Filters:</span>
<div className="flex flex-wrap gap-2">
{selectedCategories.map((category) => (
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer"
onClick={() => handleCategoryChange(category)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="text-xs h-7 px-2 cursor-pointer"
>
Clear all
</Button>
</div>
)}
<Separator className="my-2" />
</div> </div>
{filteredIcons.length === 0 ? ( {filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto"> <div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto">
<div className="text-center"> <div className="text-center">
<h2 className="text-5xl font-semibold">We don't have this one...yet!</h2> <h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</h2>
<p className="mt-4 text-muted-foreground">
{searchQuery && selectedCategories.length > 0
? `No icons found matching "${searchQuery}" with the selected filters.`
: searchQuery
? `No icons found matching "${searchQuery}".`
: selectedCategories.length > 0
? "No icons found with the selected filters."
: "No icons found matching your criteria."}
</p>
</div> </div>
<IconSubmissionContent /> <IconSubmissionContent />
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-8"> <>
{filteredIcons.map(({ name, data }) => ( <div className="flex justify-between items-center pb-2">
<Link <p className="text-sm text-muted-foreground">
prefetch={false} Found {filteredIcons.length} icon
key={name} {filteredIcons.length !== 1 ? "s" : ""}.
href={`/icons/${name}`} </p>
className="group flex flex-col items-center p-3 sm:p-4 rounded-lg border border-border hover:border-primary hover:bg-accent transition-colors" <div className="flex items-center gap-1 text-xs text-muted-foreground">
> {getSortIcon(sortOption)}
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> <span>{getSortLabel(sortOption)}</span>
<Image </div>
src={`${BASE_URL}/${data.base}/${name}.${data.base}`} </div>
alt={`${name} icon`}
fill <IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
className="object-contain p-1 group-hover:scale-110 transition-transform" </>
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize">{name.replace(/-/g, " ")}</span>
</Link>
))}
</div>
)} )}
</> </>
) )
} }
function IconCard({
name,
data: iconData,
matchedAlias,
}: {
name: string
data: Icon
matchedAlias?: string | null
}) {
return (
<MagicCard className="rounded-md shadow-md">
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
<Image
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group- dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
</span>
{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>}
</Link>
</MagicCard>
)
}
interface IconsGridProps {
filteredIcons: { name: string; data: Icon }[]
matchedAliases: Record<string, string>
}
function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2">
{filteredIcons.slice(0, 120).map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} />
))}
</div>
{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>}
</>
)
}

View File

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils"
import type React from "react"
interface BackgroundWrapperProps {
children: React.ReactNode
}
export default function BackgroundWrapper({ children }: BackgroundWrapperProps) {
return (
<>
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-background" />
<div className="z-20 relative">{children}</div>
</>
)
}

View File

@ -3,34 +3,48 @@ 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"
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Browse icons | Dashboard Icons", const icons = await getIconsArray()
description: "Search and browse through our collection of beautiful dashboard icons for your applications", const totalIcons = icons.length
keywords: ["dashboard icons", "browse icons", "icon search", "free icons", "open source icons"],
openGraph: { return {
title: "Browse Dashboard Icons Collection", title: "Browse Icons | Free Dashboard Icons",
description: "Search and browse through our collection of beautiful dashboard icons for your applications", description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
type: "website", keywords: [
url: `${BASE_URL}/icons`, "browse icons",
images: [ "dashboard icons",
{ "icon search",
url: "/og-image-browse.png", "service icons",
width: 1200, "application icons",
height: 630, "tool icons",
alt: "Browse Dashboard Icons", "web dashboard",
type: "image/png", "app directory",
},
], ],
}, openGraph: {
twitter: { title: "Browse Icons | Free Dashboard Icons",
card: "summary_large_image", description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
title: "Browse Dashboard Icons Collection", type: "website",
description: "Search and browse through our collection of beautiful dashboard icons for your applications", url: `${BASE_URL}/icons`,
images: ["/og-image-browse.png"], images: [
}, {
alternates: { url: "/og-image.png",
canonical: `${BASE_URL}/icons`, width: 1200,
}, height: 630,
alt: "Browse Dashboard Icons Collection",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
images: ["/og-image-browse.png"],
},
alternates: {
canonical: `${BASE_URL}/icons`,
},
}
} }
export const dynamic = "force-static" export const dynamic = "force-static"
@ -38,16 +52,18 @@ export const dynamic = "force-static"
export default async function IconsPage() { export default async function IconsPage() {
const icons = await getIconsArray() const icons = await getIconsArray()
return ( return (
<div className="py-8"> <div className="isolate overflow-hidden">
<div className="space-y-4 mb-8 mx-auto max-w-[80vw]"> <div className="py-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="space-y-4 mb-8 mx-auto max-w-7xl">
<div> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 className="text-3xl font-bold">Browse icons</h1> <div>
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p> <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> </div>
</div>
<IconSearch icons={icons} /> <IconSearch icons={icons} />
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,114 +1,110 @@
import { PostHogProvider } from "@/components/PostHogProvider"; import { PostHogProvider } from "@/components/PostHogProvider"
import { Header } from "@/components/header"; import { Footer } from "@/components/footer"
import { LicenseNotice } from "@/components/license-notice"; import { HeaderWrapper } from "@/components/header-wrapper"
import type { Metadata, Viewport } from "next"; import { LicenseNotice } from "@/components/license-notice"
import { Inter } from "next/font/google"; import { getTotalIcons } from "@/lib/api"
import { Toaster } from "sonner"; import type { Metadata, Viewport } from "next"
import "./globals.css"; import { Inter } from "next/font/google"
import { ThemeProvider } from "./theme-provider"; import { Toaster } from "sonner"
import "./globals.css"
import { ThemeProvider } from "./theme-provider"
import { getDescription, websiteTitle } from "@/constants"
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
}); })
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
minimumScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: "#ffffff", themeColor: "#ffffff",
}; viewportFit: "cover",
}
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
metadataBase: new URL("https://icons.homarr.dev"), const { totalIcons } = await getTotalIcons()
title: "Dashboard Icons",
description: "Curated icons for your dashboard", return {
keywords: [ metadataBase: new URL("https://dashboardicons.com"),
"dashboard", title: websiteTitle,
"icons", description: getDescription(totalIcons),
"open source", keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
"free icons", robots: {
"dashboard design", index: true,
], follow: true,
robots: { "max-image-preview": "large",
index: true, "max-snippet": -1,
follow: true, "max-video-preview": -1,
"max-image-preview": "large", googleBot: "index, follow",
"max-snippet": -1,
"max-video-preview": -1,
googleBot: "index, follow",
},
openGraph: {
siteName: "Dashboard Icons",
type: "website",
locale: "en_US",
title: "Dashboard Icons",
description: "Curated icons for your dashboard",
url: "https://icons.homarr.dev",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Dashboard Icons",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
site: "@homarr_app",
creator: "@homarr_app",
title: "Dashboard Icons",
description: "Curated icons for your dashboard",
images: ["/og-image.png"],
},
applicationName: "Dashboard Icons",
appleWebApp: {
title: "Dashboard Icons",
statusBarStyle: "default",
capable: true,
},
alternates: {
types: {
"application/rss+xml": "https://icons.homarr.dev/rss.xml",
}, },
}, openGraph: {
icons: { siteName: "Dashboard Icons",
icon: [ type: "website",
{ locale: "en_US",
url: "/favicon.ico", title: websiteTitle,
type: "image/x-icon", description: getDescription(totalIcons),
}, url: "https://dashboardicons.com",
], images: [
shortcut: [ {
{ url: "/og-image.png",
url: "/favicon.ico", width: 1200,
type: "image/x-icon", height: 630,
}, alt: "Dashboard Icons",
], type: "image/png",
}, },
}; ],
},
twitter: {
card: "summary_large_image",
site: "@homarr_app",
creator: "@homarr_app",
title: websiteTitle,
description: getDescription(totalIcons),
images: ["/og-image.png"],
},
applicationName: "Dashboard Icons",
appleWebApp: {
title: "Dashboard Icons",
statusBarStyle: "default",
capable: true,
},
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
other: [
{
rel: "mask-icon",
url: "/safari-pinned-tab.svg",
color: "#000000",
},
],
},
manifest: "/site.webmanifest",
}
}
export default function RootLayout({ export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
children,
}: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} antialiased bg-background`}> <body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
<PostHogProvider> <PostHogProvider>
<ThemeProvider <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
attribute="class" <HeaderWrapper />
defaultTheme="system" <main className="flex-grow">{children}</main>
enableSystem <Footer />
disableTransitionOnChange
>
<Header />
<main>{children}</main>
<Toaster /> <Toaster />
<LicenseNotice /> <LicenseNotice />
</ThemeProvider> </ThemeProvider>
</PostHogProvider> </PostHogProvider>
</body> </body>
</html> </html>
); )
} }

View File

@ -1,5 +1,6 @@
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft } from "lucide-react" import { AlertTriangle, ArrowLeft, PlusCircle } from "lucide-react"
import Link from "next/link" import Link from "next/link"
export default function NotFound({ export default function NotFound({
@ -9,21 +10,38 @@ export default function NotFound({
}) { }) {
return ( return (
<div className="py-16 flex items-center justify-center"> <div className="py-16 flex items-center justify-center">
<div className="text-center space-y-6 max-w-md"> <div className="text-center space-y-8 max-w-2xl mx-auto">
<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="flex flex-col items-center">
<AlertTriangle className="w-8 h-8" /> <div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
<AlertTriangle className="w-8 h-8" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Icon not found</h1>
<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.
</p>
</div> </div>
<h1 className="text-2xl font-bold">Icon not found</h1>
<p className="text-muted-foreground">The icon you are looking for could not be found or there was an error loading it.</p> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<p className="text-muted-foreground">If you believe this is an error, please contact the maintainers of the repository.</p> <Button asChild variant="outline">
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
<Button asChild>
<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 all icons
</Link> </Link>
</Button> </Button>
</div> </div>
<div className="border-t border-border pt-8 mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">Can't find what you're looking for?</h2>
<p className="text-muted-foreground mt-2">
Contribute to our icon collection by suggesting a new icon or improving an existing one.
</p>
</div>
<div className="mt-6">
<IconSubmissionContent />
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,43 +1,58 @@
import { HeroSection } from "@/components/hero" import { HeroSection } from "@/components/hero"
import { BASE_URL } from "@/constants" import { RecentlyAddedIcons } from "@/components/recently-added-icons"
import { getTotalIcons } from "@/lib/api" import { BASE_URL, getDescription, REPO_NAME, websiteTitle } from "@/constants"
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
import type { Metadata } from "next" import type { Metadata } from "next"
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Dashboard Icons - Beautiful icons for your dashboard", const { totalIcons } = await getTotalIcons()
description: "Free, open-source icons for your dashboard. Choose from hundreds of high-quality icons for your web applications.",
keywords: ["self hosted", "dashboard icons", "free icons", "open source icons", "web dashboard", "application icons"], return {
openGraph: { title: websiteTitle,
title: "Dashboard Icons - Your definitive source for dashboard icons", description: getDescription(totalIcons),
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.", keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
type: "website", openGraph: {
url: BASE_URL, title: websiteTitle,
images: [ description: getDescription(totalIcons),
{ type: "website",
url: "/og-image.png", url: BASE_URL,
width: 1200, images: [
height: 630, {
alt: "Dashboard Icons", url: "/og-image.png",
}, width: 1200,
], height: 630,
}, alt: "Dashboard Icons",
twitter: { },
title: "Dashboard Icons - Your definitive source for dashboard icons", ],
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.", },
card: "summary_large_image", twitter: {
images: ["/og-image.png"], title: websiteTitle,
}, description: getDescription(totalIcons),
alternates: { card: "summary_large_image",
canonical: BASE_URL, images: ["/og-image.png"],
}, },
alternates: {
canonical: BASE_URL,
},
}
}
async function getGitHubStars() {
const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`)
const data = await response.json()
console.log(`GitHub stars: ${data.stargazers_count}`)
return data.stargazers_count
} }
export default async function Home() { export default async function Home() {
const { totalIcons } = await getTotalIcons() const { totalIcons } = await getTotalIcons()
const recentIcons = await getRecentlyAddedIcons(10)
const stars = await getGitHubStars()
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex flex-col min-h-screen">
<HeroSection totalIcons={totalIcons} /> <HeroSection totalIcons={totalIcons} stars={stars} />
<RecentlyAddedIcons icons={recentIcons} />
</div> </div>
) )
} }

3
web/src/app/robots.txt Normal file
View File

@ -0,0 +1,3 @@
User-Agent: *
Allow: /
Sitemap: https://dashboardicons.com/sitemap.xml

View File

@ -1,17 +1,17 @@
import { BASE_URL, WEB_URL } from "@/constants"; import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons } from "@/lib/api"; import { getAllIcons } from "@/lib/api"
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next"
export const dynamic = "force-static"; export const dynamic = "force-static"
// Helper function to format dates as YYYY-MM-DD // Helper function to format dates as YYYY-MM-DD
const formatDate = (date: Date): string => { const formatDate = (date: Date): string => {
// Format to YYYY-MM-DD // Format to YYYY-MM-DD
return date.toISOString().split('T')[0]; return date.toISOString().split("T")[0]
}; }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const iconsData = await getAllIcons(); const iconsData = await getAllIcons()
return [ return [
{ {
url: WEB_URL, url: WEB_URL,
@ -34,11 +34,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
images: [ images: [
`${BASE_URL}/png/${iconName}.png`, `${BASE_URL}/png/${iconName}.png`,
// SVG is conditional if it exists // SVG is conditional if it exists
iconsData[iconName].base === "svg" iconsData[iconName].base === "svg" ? `${BASE_URL}/svg/${iconName}.svg` : null,
? `${BASE_URL}/svg/${iconName}.svg`
: null,
`${BASE_URL}/webp/${iconName}.webp`, `${BASE_URL}/webp/${iconName}.webp`,
].filter(Boolean) as string[], ].filter(Boolean) as string[],
})), })),
]; ]
} }

View File

@ -79,7 +79,7 @@ export function Carbon() {
} }
`} `}
</style> </style>
<div className="bg-background shadow-xl flex flex-col m-4 space-y-2 rounded-l-lg"> <div className=" shadow-xl flex flex-col m-4 space-y-2 rounded-l-lg">
<div ref={ref} className="carbon-outer" /> <div ref={ref} className="carbon-outer" />
</div> </div>
</> </>

View File

@ -1,90 +1,138 @@
"use client" "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 { useRouter } from "next/navigation"
import * as React from "react" import { useCallback, useEffect, useState } from "react"
import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import Link from "next/link"
interface CommandMenuProps { interface CommandMenuProps {
icons: string[] icons: {
name: string
data: {
categories: string[]
aliases: string[]
[key: string]: unknown
}
}[]
triggerButtonId?: string
open?: boolean
onOpenChange?: (open: boolean) => void
} }
export function CommandMenu({ icons }: CommandMenuProps) { export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalOnOpenChange }: CommandMenuProps) {
const router = useRouter() const router = useRouter()
const [open, setOpen] = React.useState(false) const [internalOpen, setInternalOpen] = useState(false)
const [mounted, setMounted] = React.useState(false) const [query, setQuery] = useState("")
const [inputValue, setInputValue] = React.useState("") const isDesktop = useMediaQuery("(min-width: 768px)")
const getFilteredIcons = React.useCallback(() => {
const query = inputValue.toLowerCase().trim()
if (!query) return icons.slice(0, 75)
return icons.filter((icon) => {
const iconName = icon.toLowerCase()
if (iconName.includes(query)) return true
const parts = query.split(/\s+/)
let lastIndex = -1
return parts.every((part) => {
const index = iconName.indexOf(part, lastIndex + 1)
if (index === -1) return false
lastIndex = index
return true
})
})
}, [icons, inputValue])
const filteredIcons = getFilteredIcons() // Use either external or internal state for controlling open state
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
React.useEffect(() => { // Wrap setIsOpen in useCallback to fix dependency issue
setMounted(true) const setIsOpen = useCallback(
}, []) (value: boolean) => {
React.useEffect(() => { if (externalOnOpenChange) {
const down = (e: KeyboardEvent) => { externalOnOpenChange(value)
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { } 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() e.preventDefault()
setOpen((open) => !open) setIsOpen(!isOpen)
} }
} }
document.addEventListener("keydown", down) document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", down) return () => document.removeEventListener("keydown", handleKeyDown)
}, []) }, [isOpen, setIsOpen])
const handleInputChange = React.useCallback((value: string) => { function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) {
setInputValue(value) if (!query) {
}, []) // Return a limited number of icons when no query is provided
return iconList.slice(0, 8)
}
const handleSelectIcon = React.useCallback( // Calculate scores for each icon
(iconName: string) => { const scoredIcons = iconList.map((icon) => {
router.push(`/icons/${iconName}`) // Calculate scores for different fields
setOpen(false) const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches
},
[router], // Get max score from aliases
) const aliasScore =
if (!mounted) return null 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 ( return (
<> <CommandDialog open={isOpen} onOpenChange={setIsOpen}>
<p className="text-sm text-muted-foreground"> <CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
Press{" "} <CommandList>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100"> <CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty>
<span className="text-xs"></span>K <CommandGroup heading="Icons">
</kbd>{" "} {filteredIcons.map(({ name, data }) => {
to search // Find matched alias for display if available
</p> const matchedAlias =
<CommandDialog open={open} onOpenChange={setOpen}> query && data.aliases && data.aliases.length > 0
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} /> ? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase()))
<CommandList className="max-h-[300px]"> : null
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
{filteredIcons.map((icon) => ( return (
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)}> <CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2"> <div className="flex-shrink-0 h-5 w-5 relative">
<div className="w-2 h-2 bg-primary-foreground" /> <div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
<span className="capitalize">{icon.replace(/-/g, " ")}</span> <span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
</Link> </div>
</CommandItem> </div>
))} <span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span>
</CommandList> {matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
</CommandDialog> {!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>
) )
} }

View File

@ -0,0 +1,181 @@
"use client"
import { REPO_PATH } from "@/constants"
import { motion } from "framer-motion"
import { ExternalLink, Heart } from "lucide-react"
import Link from "next/link"
import { useState } from "react"
// Pre-define unique IDs for animations to avoid using array indices as keys
const HOVER_HEART_IDS = [
"hover-heart-1",
"hover-heart-2",
"hover-heart-3",
"hover-heart-4",
"hover-heart-5",
"hover-heart-6",
"hover-heart-7",
"hover-heart-8",
]
const BURST_HEART_IDS = ["burst-heart-1", "burst-heart-2", "burst-heart-3", "burst-heart-4", "burst-heart-5"]
export function Footer() {
const [isHeartHovered, setIsHeartHovered] = useState(false)
const [isHeartFilled, setIsHeartFilled] = useState(false)
// Toggle heart fill state and add extra mini hearts on click
const handleHeartClick = () => {
setIsHeartFilled(!isHeartFilled)
}
return (
<footer className="border-t py-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-rose-500/[0.03] via-transparent to-rose-500/[0.03]" />
<div className="container mx-auto mb-2 px-4 md:px-6 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.
</p>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Links</h3>
<div className="flex flex-col gap-2">
<Link href="/" className="text-sm text-muted-foreground hover: transition-colors duration-200 flex items-center w-fit">
<span>Home</span>
</Link>
<Link href="/icons" className="text-sm text-muted-foreground hover: transition-colors duration-200 flex items-center w-fit">
<span>Icons</span>
</Link>
</div>
</div>
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h3 className="font-bold text-lg text-foreground/90">Community</h3>
<div className="text-sm flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with{" "}
<div className="relative inline-block">
<motion.div
className="cursor-pointer"
onMouseEnter={() => setIsHeartHovered(true)}
onMouseLeave={() => setIsHeartHovered(false)}
onClick={handleHeartClick}
whileTap={{ scale: 0.85 }}
>
<motion.div
animate={{
scale: isHeartFilled ? [1, 1.3, 1] : 1,
}}
transition={{
duration: isHeartFilled ? 0.4 : 0,
ease: "easeInOut",
}}
>
<Heart
className="h-3.5 w-3.5 flex-shrink-0 hover:scale-125 transition-all duration-200"
fill={isHeartFilled ? "#f43f5e" : "none"}
strokeWidth={isHeartFilled ? 1.5 : 2}
/>
</motion.div>
</motion.div>
{/* Easter egg mini hearts */}
{isHeartHovered && (
<>
{HOVER_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, (i % 2 === 0 ? 1 : -1) * Math.random() * 20],
y: [0, -Math.random() * 30],
}}
transition={{
duration: 0.8 + Math.random() * 0.5,
ease: "easeOut",
delay: Math.random() * 0.2,
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className={`h-2 w-2 ${i < 3 ? "text-rose-300" : i < 6 ? "text-rose-400" : ""}`} />
</motion.div>
))}
{/* Subtle particle glow */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 3],
opacity: [0, 0.3, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-rose-500/20 pointer-events-none"
/>
</>
)}
{/* Heart fill animation extras */}
{isHeartFilled && (
<>
{/* Radiating circles on heart fill */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: [0.5, 2.5],
opacity: [0.5, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full bg-rose-500/30 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
/>
{/* Extra burst of mini hearts when filled */}
{BURST_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, Math.cos((i * Math.PI) / 2.5) * 25],
y: [0, Math.sin((i * Math.PI) / 2.5) * 25],
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className="h-2 w-2 " fill="#f43f5e" />
</motion.div>
))}
</>
)}
</div>{" "}
by Homarr Labs and the open source community.
</div>
<Link
href={REPO_PATH}
target="_blank"
rel="noopener noreferrer"
className="text-sm transition-colors duration-200 flex items-center gap-1.5 w-fit mt-1 group"
>
Contribute to this project
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
</motion.div>
</div>
</div>
</footer>
)
}

View File

@ -1,23 +0,0 @@
import { cn } from "@/lib/utils"
interface GridBackgroundProps {
className?: string
}
export function GridBackground({ className }: GridBackgroundProps) {
return (
<div className={cn("absolute inset-0 overflow-hidden", className)}>
{/* Grid pattern */}
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-slate-900 [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-slate-900" />
</div>
)
}

View File

@ -9,17 +9,23 @@ export function HeaderNav() {
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/") const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
return ( return (
<nav className="flex items-center gap-2 md:gap-6"> <nav className="flex flex-row md:items-center items-start gap-4 md:gap-6">
<Link <Link
href="/" href="/"
className={cn("text-sm font-medium transition-colors hover:text-primary", pathname === "/" && "text-primary font-semibold")} className={cn(
"text-sm font-medium transition-colors dark:hover:text-rose-400 cursor-pointer",
pathname === "/" && "text-primary font-semibold",
)}
> >
Home Home
</Link> </Link>
<Link <Link
prefetch prefetch
href="/icons" href="/icons"
className={cn("text-sm font-medium transition-colors hover:text-primary", isIconsActive && "text-primary font-semibold")} className={cn(
"text-sm font-medium transition-colors dark:hover:text-rose-400 cursor-pointer",
isIconsActive && "text-primary font-semibold",
)}
> >
Icons Icons
</Link> </Link>

View File

@ -0,0 +1,5 @@
import { Header } from "./header"
export function HeaderWrapper() {
return <Header />
}

View File

@ -1,33 +1,118 @@
"use client"
import { IconSubmissionForm } from "@/components/icon-submission-form" import { IconSubmissionForm } from "@/components/icon-submission-form"
import { ThemeSwitcher } from "@/components/theme-switcher" import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants" import { REPO_PATH } from "@/constants"
import { getAllIcons } from "@/lib/api" import { getIconsArray } from "@/lib/api"
import { Github } from "lucide-react" import type { IconWithName } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Search } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useEffect, useState } from "react"
import { CommandMenu } from "./command-menu" import { CommandMenu } from "./command-menu"
import { HeaderNav } from "./header-nav" import { HeaderNav } from "./header-nav"
import { Button } from "./ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
const icons = await getAllIcons() 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)
}
export async function Header() {
return ( return (
<header className="border-b"> <motion.header
<div className="px-4 md:px-12 flex items-center justify-between h-16"> className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6"> <div className="flex items-center gap-2 md:gap-6">
<Link href="/" className="text-lg md:text-xl font-bold"> <Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
Dashboard Icons <span className="transition-colors duration-300 group-hover:">Dashboard Icons</span>
</Link> </Link>
<HeaderNav /> <div className="flex-nowrap">
<HeaderNav />
</div>
</div> </div>
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
<CommandMenu icons={Object.keys(icons)} /> {/* Desktop search button */}
<IconSubmissionForm /> <div className="hidden md:block">
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary"> <Button
<Github className="h-5 w-5" /> variant="outline"
</Link> className="gap-2 cursor-pointer transition-all duration-300"
onClick={openCommandMenu}
>
<Search className="h-4 w-4 transition-all duration-300" />
<span>Find icons</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
<span className="text-xs"></span>K
</kbd>
</Button>
</div>
{/* Mobile search button */}
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 "
onClick={openCommandMenu}
>
<Search className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Find icons</span>
</Button>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2"
asChild
>
<Link href={REPO_PATH} target="_blank" className="group">
<Github className="h-5 w-5 group-hover: transition-all duration-300" />
<span className="sr-only">View on GitHub</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ThemeSwitcher /> <ThemeSwitcher />
</div> </div>
</div> </div>
</header>
{/* Single instance of CommandMenu */}
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
</motion.header>
) )
} }

View File

@ -4,10 +4,31 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { motion } from "framer-motion" import { Separator } from "@radix-ui/react-dropdown-menu"
import { Circle, Github, Search } from "lucide-react" import { motion, useAnimation, useInView } from "framer-motion"
import {
Car,
Code,
Coffee,
DollarSign,
ExternalLink,
Eye,
GitFork,
Heart,
Plus,
Search,
Server,
Share2,
Sparkles,
Star,
TrendingUp,
} from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useState } from "react" import { useEffect, useRef, useState } from "react"
import { AuroraText } from "./magicui/aurora-text"
import { InteractiveHoverButton } from "./magicui/interactive-hover-button"
import { NumberTicker } from "./magicui/number-ticker"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"
interface IconCardProps { interface IconCardProps {
name: string name: string
@ -16,7 +37,7 @@ interface IconCardProps {
function IconCard({ name, imageUrl }: IconCardProps) { function IconCard({ name, imageUrl }: IconCardProps) {
return ( return (
<Card className="p-4 hover:shadow-md transition-shadow duration-300 flex flex-col items-center gap-2 cursor-pointer group"> <Card className="p-4 flex flex-col items-center gap-2 cursor-pointer group hover-lift card-hover">
<div className="w-16 h-16 flex items-center justify-center"> <div className="w-16 h-16 flex items-center justify-center">
<img src={imageUrl} alt={name} className="max-w-full max-h-full" /> <img src={imageUrl} alt={name} className="max-w-full max-h-full" />
</div> </div>
@ -31,7 +52,9 @@ function ElegantShape({
width = 400, width = 400,
height = 100, height = 100,
rotate = 0, rotate = 0,
gradient = "from-background/[0.1]", gradient = "from-rose-500/[0.5]",
mobileWidth,
mobileHeight,
}: { }: {
className?: string className?: string
delay?: number delay?: number
@ -39,51 +62,78 @@ function ElegantShape({
height?: number height?: number
rotate?: number rotate?: number
gradient?: string gradient?: string
mobileWidth?: number
mobileHeight?: number
}) { }) {
const controls = useAnimation()
const [isMobile, setIsMobile] = useState(false)
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.1 })
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
useEffect(() => {
if (isInView) {
controls.start({
opacity: 1,
y: 0,
rotate: rotate,
transition: {
type: "spring",
stiffness: 50,
damping: 20,
duration: 1.8,
delay,
ease: [0.23, 0.86, 0.39, 0.96],
opacity: { duration: 1.2 },
},
})
}
}, [controls, delay, isInView, rotate])
return ( return (
<motion.div <motion.div
ref={ref}
initial={{ initial={{
opacity: 0, opacity: 0,
y: -150, y: -150,
rotate: rotate - 15, rotate: rotate - 15,
}} }}
animate={{ animate={controls}
opacity: 1, className={cn("absolute will-change-transform", className)}
y: 0,
rotate: rotate,
}}
transition={{
duration: 2.4,
delay,
ease: [0.23, 0.86, 0.39, 0.96],
opacity: { duration: 1.2 },
}}
className={cn("absolute", className)}
> >
<motion.div <motion.div
animate={{ animate={{
y: [0, 15, 0], y: [0, 15, 0],
}} }}
transition={{ transition={{
duration: 12, duration: 8 + Math.random() * 4, // Random duration between 8-12s for varied movement
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut", ease: "easeInOut",
repeatType: "reverse",
}} }}
style={{ style={{
width, width: isMobile && mobileWidth ? mobileWidth : width,
height, height: isMobile && mobileHeight ? mobileHeight : height,
}} }}
className="relative" className="relative will-change-transform"
> >
<div <div
className={cn( className={cn(
"absolute inset-0 rounded-full", "absolute inset-0 rounded-full",
"bg-gradient-to-r to-transparent", "bg-gradient-to-r from-rose-500/[0.6] via-rose-500/[0.4] to-rose-500/[0.1]",
gradient, gradient,
"backdrop-blur-[2px] border-2 border-white/[0.15]", "backdrop-blur-[3px]",
"shadow-[0_8px_32px_0_rgba(255,255,255,0.1)]", "shadow-[0_0_40px_0_rgba(244,63,94,0.35),inset_0_0_0_1px_rgba(244,63,94,0.2)]",
"after:absolute after:inset-0 after:rounded-full", "after:absolute after:inset-0 after:rounded-full",
"after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.2),transparent_70%)]", "after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.4),transparent_70%)]",
)} )}
/> />
</motion.div> </motion.div>
@ -91,33 +141,22 @@ function ElegantShape({
) )
} }
export function HeroSection({ totalIcons }: { totalIcons: number }) { export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars: number }) {
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const fadeUpVariants = {
hidden: { opacity: 0, y: 30 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
duration: 1,
delay: 0.5 + i * 0.2,
ease: [0.25, 0.4, 0.25, 1],
},
}),
}
return ( return (
<div className="relative pt-40 w-full flex items-center justify-center overflow-hidden bg-background"> <div className="relative w-full flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/[0.05] via-transparent to-rose-500/[0.05] blur-3xl" /> <div className="absolute inset-0 bg-gradient-to-br from-rose-500/[0.1] via-transparent to-rose-500/[0.1] blur-3xl" />
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<ElegantShape <ElegantShape
delay={0.3} delay={0.3}
width={600} width={600}
height={140} height={140}
mobileWidth={300}
mobileHeight={80}
rotate={12} rotate={12}
gradient="from-indigo-500/[0.15]" gradient="from-rose-500/[0.6]"
className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]" className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]"
/> />
@ -125,8 +164,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.5} delay={0.5}
width={500} width={500}
height={120} height={120}
mobileWidth={250}
mobileHeight={70}
rotate={-15} rotate={-15}
gradient="from-rose-500/[0.15]" gradient="from-rose-500/[0.55]"
className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]" className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]"
/> />
@ -134,8 +175,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.4} delay={0.4}
width={300} width={300}
height={80} height={80}
mobileWidth={150}
mobileHeight={50}
rotate={-8} rotate={-8}
gradient="from-violet-500/[0.15]" gradient="from-rose-500/[0.65]"
className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]" className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]"
/> />
@ -143,8 +186,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.6} delay={0.6}
width={200} width={200}
height={60} height={60}
mobileWidth={100}
mobileHeight={40}
rotate={20} rotate={20}
gradient="from-amber-500/[0.15]" gradient="from-rose-500/[0.58]"
className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]" className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]"
/> />
@ -152,73 +197,39 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
delay={0.7} delay={0.7}
width={150} width={150}
height={40} height={40}
mobileWidth={80}
mobileHeight={30}
rotate={-25} rotate={-25}
gradient="from-cyan-500/[0.15]" gradient="from-rose-500/[0.62]"
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]" className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
/> />
</div> </div>
<div className="relative z-10 container mx-auto px-4 md:px-6"> <div className="relative z-10 container mx-auto px-4 md:px-6 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 ">
<Link prefetch href="https://github.com/homarr-labs" target="_blank" rel="noopener noreferrer" className="mx-auto"> <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-2000 ">
<motion.div variants={fadeUpVariants} custom={0} initial="hidden" animate="visible"> Your definitive source for
<Card className="p-2 flex flex-row items-center gap-2 hover:scale-105 transition-all duration-300"> <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-[1s] 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" />
<Circle className="h-2 w-2 fill-rose-500/80" /> <br />
<span className="text-sm text-foreground/60 tracking-wide">by homarr-labs</span> <Sparkles className="absolute -left-1 -top-3 text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[159%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-[1s] 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" />
</Card> <AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
</motion.div> </h1>
</Link>
<motion.div custom={1} variants={fadeUpVariants} initial="hidden" animate="visible"> <p className="text-sm sm:text-base md:text-xl text-muted-foreground leading-relaxed mb-8 font-light tracking-wide max-w-2xl mx-auto px-4 motion-preset-slide-down motion-duration-2000">
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold mb-6 md:mb-8 tracking-tight"> A collection of <NumberTicker value={totalIcons} className="font-bold tracking-tighter text-muted-foreground" /> curated icons
<span className="bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/80"> for services, applications and tools, designed specifically for dashboards and app directories.
Your definitive source for </p>
</span> <div className="flex flex-col gap-4 max-w-3xl mx-auto">
<br /> <SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
<span className={cn("bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-foreground/90 to-rose-300")}> <div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-2000">
dashboard icons. <Link href="/icons">
</span> <InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
</h1> </Link>
</motion.div> <GiveUsAStarButton stars={stars} />
<GiveUsMoneyButton />
<motion.div custom={2} variants={fadeUpVariants} initial="hidden" animate="visible"> <GiveUsLoveButton />
<p className="text-base sm:text-lg md:text-xl text-muted-foreground mb-8 leading-relaxed font-light tracking-wide max-w-2xl mx-auto px-4">
A collection of {totalIcons} beautiful, clean and consistent icons for your dashboard, application, or website.
</p>
</motion.div>
<motion.div
custom={3}
variants={fadeUpVariants}
initial="hidden"
animate="visible"
className="flex flex-col items-center gap-6 mb-12"
>
<form action="/icons" method="GET" className="relative w-full max-w-md">
<Input
name="q"
type="search"
placeholder={`Search ${totalIcons} icons...`}
className="pl-10 h-12 rounded-lg"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
</form>
<div className="flex gap-4">
<Button variant="default" className="rounded-lg" size="lg" asChild>
<Link href="/icons" className="flex items-center">
Browse all icons
</Link>
</Button>
<Button variant="outline" size="lg" className="gap-2" asChild>
<Link href="https://github.com/homarr-labs/dashboard-icons" target="_blank" rel="noopener noreferrer">
GitHub
<Github className="h-4 w-4" />
</Link>
</Button>
</div> </div>
</motion.div> </div>
</div> </div>
</div> </div>
@ -226,3 +237,252 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
</div> </div>
) )
} }
export default function GiveUsAStarButton({ stars }: { stars: string | number }) {
return (
<HoverCard openDelay={200} closeDelay={200}>
<HoverCardTrigger asChild>
<Link
href="https://github.com/homarr-labs/dashboard-icons"
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm md:text-base"
>
<Button variant="outline" className="h-9 md:h-10 px-4" asChild>
<div>
<p>Give us a star</p>
<Star className="h-4 w-4 ml-1 text-yellow-500 fill-yellow-500" />
<span className="text-xs text-muted-foreground">{stars}</span>
</div>
</Button>
</Link>
</HoverCardTrigger>
<HoverCardContent className="w-96">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
What is Starring?
</h4>
<p className="text-sm text-muted-foreground">
Starring a repository on GitHub is like bookmarking it.
<br /> It helps you keep track of projects you find interesting and shows appreciation to the project maintainers.
<br /> You can star a repository by clicking the 'Star' button, usually found in the top-right corner of the repository's page
on GitHub.
</p>
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">How your star helps us:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<TrendingUp className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Increases our visibility in GitHub search results</span>
</li>
<li className="flex items-start gap-2">
<Eye className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Attracts more contributors to improve the project</span>
</li>
<li className="flex items-start gap-2">
<GitFork className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Encourages more forks and community involvement</span>
</li>
<li className="flex items-start gap-2">
<Plus className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Grow the library with more icons</span>
</li>
</ul>
</div>
<div className="flex justify-between items-center pt-2">
<Button
variant="default"
size="sm"
className="bg-primary hover:bg-primary/90"
onClick={() => window.open("https://github.com/homarr-labs/dashboard-icons", "_blank")}
>
Star
</Button>
<Button
variant="link"
size="sm"
className="flex items-center gap-1 text-xs text-secondary-foreground"
onClick={() =>
window.open("https://docs.github.com/get-started/exploring-projects-on-github/saving-repositories-with-stars", "_blank")
}
>
Learn More
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
</HoverCardContent>
</HoverCard>
)
}
export function GiveUsLoveButton() {
return (
<HoverCard openDelay={200} closeDelay={200}>
<HoverCardTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-4 cursor-pointer">
<div className="flex items-center gap-2">
<p>Give us love</p>
<Heart className="h-4 w-4 ml-1 fill-red-500 text-red-500" />
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-96">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none flex items-center gap-2">
<Heart className="h-4 w-4 fill-red-500 text-red-500" />
Support us without spending
</h4>
<p className="text-sm text-muted-foreground">We keep our service free through minimal, non-intrusive ads.</p>
</div>
<div className="flex gap-2 items-start">
<div className="space-y-1">
<p className="text-sm font-medium text-primary">Please consider disabling your ad-blocker</p>
<p className="text-xs text-primary/80">
We only show ads on the icon detail pages (/icons/{"{id}"}) and never on the main site.
</p>
<p className="text-xs text-primary/80 mt-2 italic">
Note: If you use a network-wide ad blocker (like Pi-hole or AdGuard Home), you may need to whitelist "carbonads.net"
specifically.
</p>
</div>
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">Our Privacy Promise:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>We don't track your browsing habits</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>We don't sell your personal data</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>We only use essential cookies</span>
</li>
</ul>
</div>
<Separator className="bg-secondary/20" />
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground flex items-center gap-2">
<Share2 className="h-4 w-4 text-primary" />
Spread the word
</h5>
<p className="text-xs text-secondary-foreground/80">
Don't want to disable your ad blocker? You can still help us by sharing our website with others who might find it useful.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
)
}
export function GiveUsMoneyButton() {
const openCollectiveUrl = "https://opencollective.com/homarr"
return (
<HoverCard openDelay={200} closeDelay={200}>
<HoverCardTrigger asChild>
<Link target="_blank" rel="noopener noreferrer" href={openCollectiveUrl}>
<Button variant="outline" className="h-9 md:h-10 px-4" asChild>
<div className="flex items-center gap-2">
<p>Give us money</p>
<DollarSign className="h-4 w-4 ml-1 text-yellow-500" />
</div>
</Button>
</Link>
</HoverCardTrigger>
<HoverCardContent className="w-96">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none flex items-center gap-2">
<DollarSign className="h-4 w-4 text-yellow-500" />
Support our open source work
</h4>
<p className="text-sm text-muted-foreground">Your donations help us maintain and improve our free, open-source project.</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-primary">What is OpenCollective?</p>
<p className="text-xs text-primary/80">
OpenCollective is a transparent funding platform for open source projects. All donations and expenses are publicly visible,
ensuring complete transparency in how funds are used.
</p>
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">Where your money goes:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<Server className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Hosting and infrastructure costs</span>
</li>
<li className="flex items-start gap-2">
<Code className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Development time for new features</span>
</li>
<li className="flex items-start gap-2">
<Coffee className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Coffee to fuel late-night coding sessions</span>
</li>
<li className="flex items-start gap-2 line-through opacity-70">
<Car className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>A new Lamborghini (although we'd love to)</span>
</li>
</ul>
</div>
<div className="flex justify-between items-center pt-2">
<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer">
<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90">
Donate
</Button>
</Link>
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-secondary-foreground">
View expenses
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
)
}
interface SearchInputProps {
searchQuery: string
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
totalIcons: number
}
function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputProps) {
return (
<form action="/icons" method="GET" className="relative group">
<Input
name="q"
autoFocus
type="search"
placeholder={`Find any of ${totalIcons} icons by name or category...`}
className="pl-10 h-10 md:h-12 rounded-lg w-full border-border focus:border-primary/20 text-sm md:text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 md:h-5 w-4 md:w-5 text-muted-foreground group-focus-within: transition-all duration-300" />
</form>
)
}

View File

@ -6,13 +6,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants" import { BASE_URL, REPO_PATH } from "@/constants"
import type { AuthorData, Icon } from "@/types/icons" import type { AuthorData, Icon } from "@/types/icons"
import confetti from "canvas-confetti"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Check, Copy, Download, Github } from "lucide-react" import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useState } from "react" import { useCallback, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Carbon } from "./carbon" import { Carbon } from "./carbon"
import { MagicCard } from "./magicui/magic-card"
import { Badge } from "./ui/badge"
export type IconDetailsProps = { export type IconDetailsProps = {
icon: string icon: string
@ -42,7 +46,38 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
const availableFormats = getAvailableFormats() const availableFormats = getAvailableFormats()
const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({}) const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({})
const handleCopy = (url: string, variantKey: string) => { // Launch confetti from the pointer position
const launchConfetti = useCallback((originX?: number, originY?: number) => {
const defaults = {
startVelocity: 15,
spread: 180,
ticks: 50,
zIndex: 20,
disableForReducedMotion: true,
colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
}
// If we have origin coordinates, use them
if (originX !== undefined && originY !== undefined) {
confetti({
...defaults,
particleCount: 50,
origin: {
x: originX / window.innerWidth,
y: originY / window.innerHeight,
},
})
} else {
// Default to center of screen
confetti({
...defaults,
particleCount: 50,
origin: { x: 0.5, y: 0.5 },
})
}
}, [])
const handleCopy = (url: string, variantKey: string, event?: React.MouseEvent) => {
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
setCopiedVariants((prev) => ({ setCopiedVariants((prev) => ({
...prev, ...prev,
@ -55,118 +90,177 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
})) }))
}, 2000) }, 2000)
// Launch confetti from click position or center of screen
if (event) {
launchConfetti(event.clientX, event.clientY)
} else {
launchConfetti()
}
toast.success("URL copied", { toast.success("URL copied", {
description: "The icon URL has been copied to your clipboard", description: "The icon URL has been copied to your clipboard. Ready to use!",
}) })
} }
const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => {
event.preventDefault()
// Launch confetti from download button position
launchConfetti(event.clientX, event.clientY)
try {
// Show loading toast
toast.loading("Preparing download...")
// Fetch the file first as a blob
const response = await fetch(url)
const blob = await response.blob()
// Create a blob URL and use it for download
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
// Clean up
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
toast.dismiss()
toast.success("Download started", {
description: "Your icon file is being downloaded and will be saved to your device.",
})
} catch (error) {
console.error("Download error:", error)
toast.dismiss()
toast.error("Download failed", {
description: "There was an error downloading the file. Please try again.",
})
}
}
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => { const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const { resolvedTheme } = useTheme()
const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
const url = `${BASE_URL}/${format}/${variantName}.${format}` const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}` const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
const variantKey = `${format}-${theme || "default"}` const variantKey = `${format}-${theme || "default"}`
const isCopied = copiedVariants[variantKey] || false const isCopied = copiedVariants[variantKey] || false
return ( return (
<TooltipProvider key={variantKey}> <TooltipProvider key={variantKey} delayDuration={500}>
<div className="flex flex-col items-center bg-card rounded-lg p-4 border shadow-sm hover:shadow-md transition-all"> <MagicCard gradientColor={resolvedTheme === "dark" ? "#262626" : "#D9D9D955"} className="p-0 rounded-md">
<Tooltip> <div className="flex flex-col items-center p-4 transition-all">
<TooltipTrigger asChild> <Tooltip>
<motion.div <TooltipTrigger asChild>
className="relative w-28 h-28 mb-3 cursor-pointer rounded-md overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleCopy(url, variantKey)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-md z-10 transition-colors" />
<motion.div <motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-md" className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
initial={{ opacity: 0 }} whileHover={{ scale: 1.05 }}
animate={{ opacity: isCopied ? 1 : 0 }} whileTap={{ scale: 0.95 }}
transition={{ duration: 0.2 }} onClick={(e) => handleCopy(imageUrl, variantKey, e)}
> >
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div <motion.div
initial={{ scale: 0.5, opacity: 0 }} className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
animate={{ scale: isCopied ? 1 : 0.5, opacity: isCopied ? 1 : 0 }} initial={{ opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }} animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
> >
<Check className="w-8 h-8 text-primary" /> <motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: isCopied ? 1 : 0.5,
opacity: isCopied ? 1 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
</motion.div> </motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-4"
/>
</motion.div> </motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Image <p className="text-sm font-medium">{format.toUpperCase()}</p>
src={url}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-2"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p> <div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<div className="flex gap-2 mt-3 w-full justify-center"> <TooltipTrigger asChild>
<Tooltip> <Button
<TooltipTrigger asChild> variant="outline"
<Button variant="outline" size="icon" className="h-8 w-8" asChild> size="icon"
<a href={url} download={`${iconName}.${format}`}> className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</a> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>Download icon file</p>
<p>Download icon</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8 cursor-pointer" className="h-8 w-8 rounded-lg cursor-pointer"
onClick={() => handleCopy(url, `btn-${variantKey}`)} onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
> >
{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>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Copy URL to clipboard</p> <p>Copy direct URL to clipboard</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8" asChild> <Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer"> <Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" /> <Github className="w-4 h-4" />
</Link> </Link>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>View on GitHub</p> <p>View on GitHub</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div>
</div> </div>
</div> </MagicCard>
</TooltipProvider> </TooltipProvider>
) )
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto pt-12 pb-14">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column: Icon Info and Author */} {/* Left Column: Icon Info and Author */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card className="h-full"> <Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="relative w-32 h-32 bg-background rounded-xl overflow-hidden border flex items-center justify-center p-3 mb-4"> <div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 ">
<Image <Image
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`} src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
width={96} width={96}
@ -179,8 +273,8 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-4">
<div className="space-y-3"> <div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm"> <p className="text-sm">
@ -194,44 +288,79 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<AvatarImage src={authorData.avatar_url} alt={authorName} /> <AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback> <AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar> </Avatar>
<Link {authorData.html_url ? (
href={authorData.html_url} <Link
target="_blank" href={authorData.html_url}
rel="noopener noreferrer" target="_blank"
className="text-primary hover:underline text-sm" rel="noopener noreferrer"
> className="text-primary hover:underline text-sm"
{authorName} >
</Link> {authorName}
</Link>
) : (
<span className="text-sm">{authorName}</span>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{iconData.categories && iconData.categories.length > 0 && ( {iconData.categories && iconData.categories.length > 0 && (
<div className="space-y-3"> <div>
<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3> <h3 className="text-sm font-semibold text-muted-foreground">Categories</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{iconData.categories.map((category) => ( {iconData.categories.map((category) => (
<span key={category} className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold"> <Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer">
{category} <Badge
</span> variant="outline"
className="inline-flex items-center border border-primary/20 hover:border-primary px-2.5 py-0.5 text-sm"
>
{category
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</Badge>
</Link>
))} ))}
</div> </div>
</div> </div>
)} )}
{iconData.aliases && iconData.aliases.length > 0 && ( {iconData.aliases && iconData.aliases.length > 0 && (
<div className="space-y-3"> <div>
<h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3> <h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-2">
{iconData.aliases.map((alias) => ( {iconData.aliases.map((alias) => (
<span key={alias} className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs"> <Badge
variant="outline"
key={alias}
className="inline-flex items-center px-2.5 py-1 text-xs"
title={`This icon can also be found by searching for "${alias}"`}
>
{alias} {alias}
</span> </Badge>
))} ))}
</div> </div>
</div> </div>
)} )}
<div>
<h3 className="text-sm font-semibold text-muted-foreground">About this icon</h3>
<div className="text-xs text-muted-foreground space-y-2">
<p>
Available in{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
: `${availableFormats[0].toUpperCase()} format`}{" "}
with a base format of {iconData.base.toUpperCase()}.
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
</p>
<p>
Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user
experience.
</p>
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -239,7 +368,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{/* Middle Column: Icon variants */} {/* Middle Column: Icon variants */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="h-full"> <Card className="h-full bg-background/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle>Icon variants</CardTitle> <CardTitle>Icon variants</CardTitle>
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription> <CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
@ -252,20 +381,20 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
) : ( ) : (
<div className="space-y-10"> <div className="space-y-10">
<div> <div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded-full bg-primary" /> <Sun className="w-4 h-4 text-amber-500" />
Light theme Light theme
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg ">
{availableFormats.map((format) => renderVariant(format, icon, "light"))} {availableFormats.map((format) => renderVariant(format, icon, "light"))}
</div> </div>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded-full bg-primary" /> <Moon className="w-4 h-4 text-indigo-500" />
Dark theme Dark theme
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg ">
{availableFormats.map((format) => renderVariant(format, icon, "dark"))} {availableFormats.map((format) => renderVariant(format, icon, "dark"))}
</div> </div>
</div> </div>
@ -277,25 +406,25 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{/* Right Column: Technical details */} {/* Right Column: Technical details */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card className="h-full"> <Card className="h-full bg-background/50 border shadow-lg">
<CardHeader> <CardHeader>
<CardTitle>Technical details</CardTitle> <CardTitle>Technical details</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3> <h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-primary/80" /> <FileType className="w-4 h-4 text-blue-500" />
<div className="px-3 py-1.5 bg-muted rounded-md text-sm font-medium">{iconData.base.toUpperCase()}</div> <div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">{iconData.base.toUpperCase()}</div>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3> <h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{availableFormats.map((format) => ( {availableFormats.map((format) => (
<div key={format} className="px-3 py-1.5 bg-muted rounded-md text-xs font-medium"> <div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
{format.toUpperCase()} {format.toUpperCase()}
</div> </div>
))} ))}
@ -303,28 +432,24 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</div> </div>
{iconData.colors && ( {iconData.colors && (
<div className="space-y-3"> <div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3> <h3 className="text-sm font-semibold text-muted-foreground">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">
<span className="w-3 h-3 rounded-full bg-primary/80" /> <PaletteIcon className="w-4 h-4 text-purple-500" />
<span className="capitalize font-medium text-sm">{theme}:</span> <span className="capitalize font-medium text-sm">{theme}:</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs">{variant}</code> <code className=" border border-border px-2 py-0.5 rounded-lg text-xs">{variant}</code>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="space-y-3"> <div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3> <h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
<Button variant="outline" className="w-full" asChild> <Button variant="outline" className="w-full" asChild>
<Link <Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
href={`${REPO_PATH}/tree/main/${iconData.base}/${icon}.${iconData.base}`}
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-4 h-4 mr-2" /> <Github className="w-4 h-4 mr-2" />
View on GitHub View on GitHub
</Link> </Link>

View File

@ -12,31 +12,31 @@ export const ISSUE_TEMPLATES = [
{ {
id: "add_monochrome_icon", id: "add_monochrome_icon",
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 both light and dark versions for optimal theme compatibility.",
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 normal 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 that works well across both light and dark 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: "Use this template to update an existing icon. Monochrome icons need both light and dark versions.", description: "Improve an existing icon by updating both light and dark versions.",
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 normal icon",
description: "Use this template to update an existing icon. Normal icons work for both light and dark themes.", description: "Improve an existing icon that works across both light and dark themes.",
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: "Something else",
description: "You'd like to do something else? Use this template to create a new issue.", description: "Create a custom issue for other suggestions, bug reports, or improvements.",
url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`, url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`,
}, },
] ]
@ -45,17 +45,20 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{ISSUE_TEMPLATES.map((template) => ( {ISSUE_TEMPLATES.map((template) => (
<Link key={template.id} href={template.url} className="w-full" target="_blank" rel="noopener noreferrer"> <Link key={template.id} href={template.url} className="w-full group z10" target="_blank" rel="noopener noreferrer">
<Button <Button
variant="secondary"
key={template.id} key={template.id}
variant="outline" className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer transition-all duration-300"
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer" asChild
> >
<div className="flex w-full items-center justify-between"> <div>
<span className="font-medium">{template.name}</span> <div className="flex w-full items-center justify-between">
<ExternalLink className="h-4 w-4 text-muted-foreground" /> <span className="font-medium transition-all duration-300">{template.name}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300" />
</div>
<span className="text-xs text-muted-foreground">{template.description}</span>
</div> </div>
<span className="text-xs text-muted-foreground">{template.description}</span>
</Button> </Button>
</Link> </Link>
))} ))}
@ -69,14 +72,17 @@ export function IconSubmissionForm() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex"> <Button
<PlusCircle className="h-4 w-4" /> Suggest new icon 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
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="md:max-w-4xl backdrop-blur-2xl"> <DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background">
<DialogHeader> <DialogHeader>
<DialogTitle>Suggest a new icon</DialogTitle> <DialogTitle>Contribute a new icon</DialogTitle>
<DialogDescription>You can suggest a new icon by creating an issue on GitHub using one of the templates below.</DialogDescription> <DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mt-4"> <div className="mt-4">
<IconSubmissionContent onClose={() => setOpen(false)} /> <IconSubmissionContent onClose={() => setOpen(false)} />

View File

@ -38,11 +38,11 @@ export function LicenseNotice() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<p> <p>
Unless otherwise indicated, all images and assets are the property of their respective owners and used for identification All product names, trademarks, and registered trademarks are the property of their respective owners. Icons are used for
purposes only. identification purposes only and do not imply endorsement.
</p> </p>
<p> <p>
Read the{" "} View our{" "}
<Link <Link
href={`${REPO_PATH}/blob/main/LICENSE`} href={`${REPO_PATH}/blob/main/LICENSE`}
className="underline hover:text-foreground" className="underline hover:text-foreground"

View File

@ -0,0 +1,33 @@
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>
)
}

View File

@ -0,0 +1,37 @@
"use client"
import type React from "react"
import { memo } from "react"
interface AuroraTextProps {
children: React.ReactNode
className?: string
colors?: string[]
speed?: number
}
export const AuroraText = memo(
({ children, className = "", colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"], speed = 1 }: AuroraTextProps) => {
const gradientStyle = {
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${colors[0]})`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
animationDuration: `${10 / speed}s`,
}
return (
<span className={`relative inline-block ${className}`}>
<span className="sr-only">{children}</span>
<span
className="relative animate-aurora bg-[length:200%_auto] bg-clip-text text-transparent"
style={gradientStyle}
aria-hidden="true"
>
{children}
</span>
</span>
)
},
)
AuroraText.displayName = "AuroraText"

View File

@ -0,0 +1,33 @@
import { cn } from "@/lib/utils"
import { ArrowRight } from "lucide-react"
import React from "react"
import { Button } from "../ui/button"
interface InteractiveHoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const InteractiveHoverButton = React.forwardRef<HTMLButtonElement, InteractiveHoverButtonProps>(
({ children, className, ...props }, ref) => {
return (
<Button
ref={ref}
variant="outline"
className={cn(
"group relative w-auto cursor-pointer overflow-hidden rounded-full border bg-background p-2 px-6 text-center font-semibold",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary transition-all duration-300 group-hover:scale-[100.8]" />
<span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">{children}</span>
</div>
<div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-primary-foreground opacity-0 transition-all duration-300 group-hover:-translate-x-1 group-hover:opacity-100">
<span>{children}</span>
<ArrowRight />
</div>
</Button>
)
},
)
InteractiveHoverButton.displayName = "InteractiveHoverButton"

View File

@ -0,0 +1,106 @@
"use client"
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
import type React from "react"
import { useCallback, useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
interface MagicCardProps {
children?: React.ReactNode
className?: string
gradientSize?: number
gradientColor?: string
gradientOpacity?: number
gradientFrom?: string
gradientTo?: string
}
export function MagicCard({
children,
className,
gradientSize = 200,
gradientColor = "",
gradientOpacity = 0.8,
gradientFrom = "#ff0a54",
gradientTo = "#f9bec7",
}: MagicCardProps) {
const cardRef = useRef<HTMLDivElement>(null)
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (cardRef.current) {
const { left, top } = cardRef.current.getBoundingClientRect()
const clientX = e.clientX
const clientY = e.clientY
mouseX.set(clientX - left)
mouseY.set(clientY - top)
}
},
[mouseX, mouseY],
)
const handleMouseOut = useCallback(
(e: MouseEvent) => {
if (!e.relatedTarget) {
document.removeEventListener("mousemove", handleMouseMove)
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}
},
[handleMouseMove, mouseX, gradientSize, mouseY],
)
const handleMouseEnter = useCallback(() => {
document.addEventListener("mousemove", handleMouseMove)
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}, [handleMouseMove, mouseX, gradientSize, mouseY])
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseout", handleMouseOut)
document.addEventListener("mouseenter", handleMouseEnter)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseout", handleMouseOut)
document.removeEventListener("mouseenter", handleMouseEnter)
}
}, [handleMouseEnter, handleMouseMove, handleMouseOut])
useEffect(() => {
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}, [gradientSize, mouseX, mouseY])
return (
<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}>
<motion.div
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
var(--border) 100%
)
`,
}}
/>
<div className="absolute inset-px rounded-[inherit] bg-background" />
<motion.div
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
`,
opacity: gradientOpacity,
}}
/>
<div className="relative">{children}</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
import { cn } from "@/lib/utils"
import type { ComponentPropsWithoutRef } from "react"
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
/**
* Optional CSS class name to apply custom styles
*/
className?: string
/**
* Whether to reverse the animation direction
* @default false
*/
reverse?: boolean
/**
* Whether to pause the animation on hover
* @default false
*/
pauseOnHover?: boolean
/**
* Content to be displayed in the marquee
*/
children: React.ReactNode
/**
* Whether to animate vertically instead of horizontally
* @default false
*/
vertical?: boolean
/**
* Number of times to repeat the content
* @default 4
*/
repeat?: number
}
export function Marquee({
className,
reverse = false,
pauseOnHover = false,
children,
vertical = false,
repeat = 4,
...props
}: MarqueeProps) {
return (
<div
{...props}
className={cn(
"group flex overflow-hidden p-2 [--duration:40s] [--gap:1rem] [gap:var(--gap)]",
{
"flex-row": !vertical,
"flex-col": vertical,
},
className,
)}
>
{Array(repeat)
.fill(0)
.map((_, i) => (
<div
key={i}
className={cn("flex shrink-0 justify-around [gap:var(--gap)]", {
"animate-marquee flex-row": !vertical,
"animate-marquee-vertical flex-col": vertical,
"group-hover:[animation-play-state:paused]": pauseOnHover,
"[animation-direction:reverse]": reverse,
})}
>
{children}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,67 @@
"use client";
import { useInView, useMotionValue, useSpring } from "motion/react";
import { ComponentPropsWithoutRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number;
startValue?: number;
direction?: "up" | "down";
delay?: number;
decimalPlaces?: number;
}
export function NumberTicker({
value,
startValue = 0,
direction = "up",
delay = 0,
className,
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : startValue);
const springValue = useSpring(motionValue, {
damping: 30,
stiffness: 100,
});
const isInView = useInView(ref, { once: true, margin: "0px" });
useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
motionValue.set(direction === "down" ? startValue : value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [motionValue, isInView, delay, value, direction, startValue]);
useEffect(
() =>
springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces],
);
return (
<span
ref={ref}
className={cn(
"inline-block tabular-nums tracking-wider",
className,
)}
{...props}
>
{startValue}
</span>
);
}

View File

@ -0,0 +1,116 @@
"use client"
import { Marquee } from "@/components/magicui/marquee"
import { BASE_URL } from "@/constants"
import { cn } from "@/lib/utils"
import type { Icon, IconWithName } from "@/types/icons"
import { format, isToday, isYesterday } from "date-fns"
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
function formatIconDate(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return "Today"
}
if (isYesterday(date)) {
return "Yesterday"
}
return format(date, "MMM d, yyyy")
}
export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
// Split icons into two rows for the marquee
const firstRow = icons.slice(0, Math.ceil(icons.length / 2))
const secondRow = icons.slice(Math.ceil(icons.length / 2))
return (
<div className="relative isolate overflow-hidden my-8">
{/* Background glow */}
<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true" />
<div className="mx-auto px-6 lg:px-8">
<div className="mx-auto 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-2000">
Recently Added Icons
</h2>
</div>
<div className="relative flex w-full flex-col items-center justify-center overflow-hidden">
<Marquee pauseOnHover className="[--duration:60s] [--gap:1em] motion-safe:motion-preset-slide-left-sm motion-duration-1000">
{firstRow.map(({ name, data }) => (
<RecentIconCard key={name} name={name} data={data} />
))}
</Marquee>
<Marquee
reverse
pauseOnHover
className="[--duration:60s] [--gap:1rem] motion-safe:motion-preset-slide-right-sm motion-duration-1000"
>
{secondRow.map(({ name, data }) => (
<RecentIconCard key={name} name={name} data={data} />
))}
</Marquee>
<div className="pointer-events-none absolute inset-y-0 left-0 w-1/4 bg-gradient-to-r from-background" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-1/4 bg-gradient-to-l from-background" />
</div>
<div className="mt-12 text-center">
<Link
href="/icons"
className="font-medium inline-flex items-center py-2 px-4 rounded-full border transition-all duration-200 group hover-lift soft-shadow"
>
View complete collection
<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" />
</Link>
</div>
</div>
</div>
)
}
// Marquee-compatible icon card
function RecentIconCard({
name,
data,
}: {
name: string
data: Icon
}) {
return (
<Link
prefetch={false}
href={`/icons/${name}`}
className={cn(
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
"transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden hover-lift",
"w-36 mx-2",
)}
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" />
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
<Image
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 hover:scale-110 transition-transform duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize dark:hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
</span>
<div className="flex items-center justify-center mt-2 w-full">
<span className="text-[10px] sm:text-xs text-muted-foreground flex items-center whitespace-nowrap hover:/70 transition-colors duration-200">
<Clock className="w-3 h-3 mr-1.5 shrink-0" />
{formatIconDate(data.update.timestamp)}
</span>
</div>
<div className="absolute top-2 right-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
<ExternalLink className="w-3 h-3 " />
</div>
</Link>
)
}

View File

@ -5,24 +5,46 @@ import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useState } from "react"
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { setTheme } = useTheme() const { setTheme } = useTheme()
const [open, setOpen] = useState(false)
return ( return (
<DropdownMenu> <TooltipProvider>
<DropdownMenuTrigger asChild> <DropdownMenu open={open} onOpenChange={setOpen}>
<Button className="hover:text-primary" variant="ghost" size="icon"> <Tooltip>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <TooltipTrigger asChild>
<Moon className=" absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <DropdownMenuTrigger asChild>
<span className="sr-only">Toggle theme</span> <Button
</Button> className=" transition-colors duration-200 group hover:ring-2 rounded-lg cursor-pointer"
</DropdownMenuTrigger> variant="ghost"
<DropdownMenuContent align="end"> size="icon"
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem> >
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 group-hover:" />
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 group-hover:" />
</DropdownMenuContent> <span className="sr-only">Toggle theme</span>
</DropdownMenu> </Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Change theme</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
) )
} }

View File

@ -1,10 +1,10 @@
"use client" "use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from "react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
function AlertDialog({ function AlertDialog({
...props ...props
@ -54,7 +54,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", " data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
@ -143,15 +143,7 @@ function AlertDialogCancel({
} }
export { export {
AlertDialog, AlertDialog, AlertDialogAction,
AlertDialogPortal, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} }

View File

@ -0,0 +1,61 @@
"use client";
import { cn } from "@/lib/utils";
import React, { ReactNode } from "react";
interface AuroraBackgroundProps extends React.HTMLProps<HTMLDivElement> {
children: ReactNode;
showRadialGradient?: boolean;
}
export const AuroraBackground = ({
className,
children,
showRadialGradient = true,
...props
}: AuroraBackgroundProps) => {
return (
<main>
<div
className={cn(
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
className,
)}
{...props}
>
<div
className="absolute inset-0 overflow-hidden"
style={
{
"--aurora":
"repeating-linear-gradient(100deg,#3b82f6_10%,#a5b4fc_15%,#93c5fd_20%,#ddd6fe_25%,#60a5fa_30%)",
"--dark-gradient":
"repeating-linear-gradient(100deg,#000_0%,#000_7%,transparent_10%,transparent_12%,#000_16%)",
"--white-gradient":
"repeating-linear-gradient(100deg,#fff_0%,#fff_7%,transparent_10%,transparent_12%,#fff_16%)",
"--blue-300": "#93c5fd",
"--blue-400": "#60a5fa",
"--blue-500": "#3b82f6",
"--indigo-300": "#a5b4fc",
"--violet-200": "#ddd6fe",
"--black": "#000",
"--white": "#fff",
"--transparent": "transparent",
} as React.CSSProperties
}
>
<div
// I'm sorry but this is what peak developer performance looks like // trigger warning
className={cn(
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
showRadialGradient &&
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`,
)}
></div>
</div>
{children}
</div>
</main>
);
};

View File

@ -1,31 +1,31 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:transition-all [&_svg]:duration-300 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:scale-[1.02]",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white shadow-sm hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 hover:shadow-md",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 hover:shadow-md",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-10 px-5 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9 rounded-md",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm transition-all duration-300 hover:shadow-md",
className className
)} )}
{...props} {...props}

View File

@ -171,7 +171,7 @@ function ChartTooltipContent({
return ( return (
<div <div
className={cn( className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl", "border-border/50 grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className className
)} )}
> >
@ -340,9 +340,9 @@ function getPayloadConfigFromPayload(
} }
export { export {
ChartContainer, ChartLegend, ChartContainer, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartTooltip, ChartStyle, ChartTooltip,
ChartTooltipContent ChartTooltipContent
} }

View File

@ -1,8 +1,8 @@
"use client" "use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -57,7 +57,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", " data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}
@ -122,14 +122,15 @@ function DialogDescription({
} }
export { export {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger
} }

View File

@ -56,7 +56,7 @@ function DrawerContent({
<DrawerPrimitive.Content <DrawerPrimitive.Content
data-slot="drawer-content" data-slot="drawer-content"
className={cn( className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col", "group/drawer-content fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
@ -119,14 +119,7 @@ function DrawerDescription({
} }
export { export {
Drawer, Drawer, DrawerClose,
DrawerPortal, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
} }

View File

@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-lg border bg-transparent px-4 py-2 text-base shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:shadow-md",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}

View File

@ -1,8 +1,8 @@
"use client" "use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar" import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -14,7 +14,7 @@ function Menubar({
<MenubarPrimitive.Root <MenubarPrimitive.Root
data-slot="menubar" data-slot="menubar"
className={cn( className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs", " flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className className
)} )}
{...props} {...props}
@ -257,20 +257,8 @@ function MenubarSubContent({
} }
export { export {
Menubar, Menubar, MenubarCheckboxItem, MenubarContent,
MenubarPortal, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup,
MenubarMenu, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
} }

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -59,7 +59,7 @@ function NavigationMenuItem({
} }
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" "group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
) )
function NavigationMenuTrigger({ function NavigationMenuTrigger({
@ -156,13 +156,6 @@ function NavigationMenuIndicator({
} }
export { export {
NavigationMenu, NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, NavigationMenuViewport
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
} }

View File

@ -1,8 +1,8 @@
"use client" "use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -58,7 +58,7 @@ function SheetContent({
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", " data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" && side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" && side === "left" &&
@ -128,12 +128,7 @@ function SheetDescription({
} }
export { export {
Sheet, Sheet, SheetClose,
SheetTrigger, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
} }

View File

@ -9,18 +9,18 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -296,7 +296,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", " relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className
)} )}
@ -313,7 +313,7 @@ function SidebarInput({
<Input <Input
data-slot="sidebar-input" data-slot="sidebar-input"
data-sidebar="input" data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn(" h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) )
@ -466,7 +466,7 @@ const sidebarMenuButtonVariants = cva(
variant: { variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", " shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
}, },
size: { size: {
default: "h-8 text-sm", default: "h-8 text-sm",
@ -683,29 +683,29 @@ function SidebarMenuSubButton({
} }
export { export {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupAction, SidebarGroupAction,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarInput, SidebarInput,
SidebarInset, SidebarInset,
SidebarMenu, SidebarMenu,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuBadge, SidebarMenuBadge,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSkeleton, SidebarMenuSkeleton,
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarProvider, SidebarProvider,
SidebarRail, SidebarRail,
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar useSidebar
} }

View File

@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -53,7 +53,7 @@ function Slider({
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
data-slot="slider-thumb" data-slot="slider-thumb"
key={index} key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>

View File

@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch" import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -21,7 +21,7 @@ function Switch({
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={cn( className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" " dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>

View File

@ -1,4 +1,10 @@
export const BASE_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons" export const BASE_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons"
export const REPO_PATH = "https://github.com/homarr-labs/dashboard-icons" export const REPO_PATH = "https://github.com/homarr-labs/dashboard-icons"
export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json" export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json"
export const WEB_URL = "https://icons.homarr.dev" export const WEB_URL = "https://dashboardicons.com"
export const REPO_NAME = "homarr-labs/dashboard-icons"
export const getDescription = (totalIcons: number) =>
`A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`
export const websiteTitle = "Free Dashboard Icons - Download High-Quality UI & App Icons"

View File

@ -0,0 +1,25 @@
"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
}

View File

@ -1,50 +1,94 @@
import { METADATA_URL } from "@/constants" import { METADATA_URL } from "@/constants"
import type { IconFile, IconWithName } from "@/types/icons" import type { IconFile, IconWithName } from "@/types/icons"
/**
* Custom error class for API errors
*/
export class ApiError extends Error {
status: number
constructor(message: string, status = 500) {
super(message)
this.name = "ApiError"
this.status = status
}
}
/** /**
* Fetches all icon data from the metadata.json file * Fetches all icon data from the metadata.json file
*/ */
export async function getAllIcons(): Promise<IconFile> { export async function getAllIcons(): Promise<IconFile> {
const file = await fetch(METADATA_URL) try {
return (await file.json()) as IconFile const response = await fetch(METADATA_URL)
if (!response.ok) {
throw new ApiError(`Failed to fetch icons: ${response.statusText}`, response.status)
}
return (await response.json()) as IconFile
} catch (error) {
if (error instanceof ApiError) {
throw error
}
console.error("Error fetching icons:", error)
throw new ApiError("Failed to fetch icons data. Please try again later.")
}
} }
/** /**
* Gets a list of all icon names. * Gets a list of all icon names.
*/ */
export const getIconNames = async (): Promise<string[]> => { export const getIconNames = async (): Promise<string[]> => {
const iconsData = await getAllIcons() try {
return Object.keys(iconsData) const iconsData = await getAllIcons()
return Object.keys(iconsData)
} catch (error) {
console.error("Error getting icon names:", error)
throw error
}
} }
/** /**
* Converts icon data to an array format for easier rendering * Converts icon data to an array format for easier rendering
*/ */
export async function getIconsArray(): Promise<IconWithName[]> { export async function getIconsArray(): Promise<IconWithName[]> {
const iconsData = await getAllIcons() try {
const iconsData = await getAllIcons()
return Object.entries(iconsData) return Object.entries(iconsData)
.map(([name, data]) => ({ .map(([name, data]) => ({
name, name,
data, data,
})) }))
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
} catch (error) {
console.error("Error getting icons array:", error)
throw error
}
} }
/** /**
* Fetches data for a specific icon * Fetches data for a specific icon
*/ */
export async function getIconData(iconName: string): Promise<IconWithName | null> { export async function getIconData(iconName: string): Promise<IconWithName | null> {
const iconsData = await getAllIcons() try {
const iconData = iconsData[iconName] const iconsData = await getAllIcons()
const iconData = iconsData[iconName]
if (!iconData) { if (!iconData) {
return null throw new ApiError(`Icon '${iconName}' not found`, 404)
} }
return { return {
name: iconName, name: iconName,
data: iconData, data: iconData,
}
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null
}
console.error("Error getting icon data:", error)
throw error
} }
} }
@ -52,23 +96,75 @@ export async function getIconData(iconName: string): Promise<IconWithName | null
* Fetches author data from GitHub API * Fetches author data from GitHub API
*/ */
export async function getAuthorData(authorId: number) { export async function getAuthorData(authorId: number) {
const response = await fetch(`https://api.github.com/user/${authorId}`, { try {
headers: { const response = await fetch(`https://api.github.com/user/${authorId}`, {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, headers: {
"Cache-Control": "public, max-age=86400", Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
}, "Cache-Control": "public, max-age=86400",
next: { revalidate: 86400 }, // Revalidate cache once a day },
}) next: { revalidate: 86400 }, // Revalidate cache once a day
return response.json() })
if (!response.ok) {
// If unauthorized or other error, return a default user object
if (response.status === 401 || response.status === 403) {
console.warn(`GitHub API rate limit or authorization issue: ${response.statusText}`)
return {
login: "unknown",
avatar_url: "https://avatars.githubusercontent.com/u/0",
html_url: "https://github.com",
name: "Unknown User",
bio: null,
}
}
throw new ApiError(`Failed to fetch author data: ${response.statusText}`, response.status)
}
return response.json()
} catch (error) {
console.error("Error fetching author data:", error)
// Even for unexpected errors, return a default user to prevent page failures
return {
login: "unknown",
avatar_url: "https://avatars.githubusercontent.com/u/0",
html_url: "https://github.com",
name: "Unknown User",
bio: null,
}
}
} }
/** /**
* Fetches featured icons for the homepage * Fetches total icon count
*/ */
export async function getTotalIcons() { export async function getTotalIcons() {
const iconsData = await getAllIcons() try {
const iconsData = await getAllIcons()
return { return {
totalIcons: Object.keys(iconsData).length, totalIcons: Object.keys(iconsData).length,
}
} catch (error) {
console.error("Error getting total icons:", error)
throw error
}
}
/**
* Fetches recently added icons sorted by timestamp
*/
export async function getRecentlyAddedIcons(limit = 8): Promise<IconWithName[]> {
try {
const icons = await getIconsArray()
return icons
.sort((a, b) => {
// Sort by timestamp in descending order (newest first)
return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
})
.slice(0, limit)
} catch (error) {
console.error("Error getting recently added icons:", error)
throw error
} }
} }

View File

@ -4,3 +4,122 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/**
* Calculate Levenshtein distance between two strings
*/
export function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
// Initialize the matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
// Fill the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost, // substitution
)
}
}
return matrix[b.length][a.length]
}
/**
* Calculate similarity score between two strings (0-1)
* Higher score means more similar
*/
export function calculateStringSimilarity(str1: string, str2: string): number {
if (!str1.length || !str2.length) return 0
if (str1 === str2) return 1
const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase())
const maxLength = Math.max(str1.length, str2.length)
return 1 - distance / maxLength
}
/**
* Check if string contains all characters from query in order
* Returns match score (0 if no match)
*/
export function containsCharsInOrder(str: string, query: string): number {
if (!query) return 1
if (!str) return 0
const normalizedStr = str.toLowerCase()
const normalizedQuery = query.toLowerCase()
let strIndex = 0
let queryIndex = 0
while (strIndex < normalizedStr.length && queryIndex < normalizedQuery.length) {
if (normalizedStr[strIndex] === normalizedQuery[queryIndex]) {
queryIndex++
}
strIndex++
}
// If we matched all characters in the query
if (queryIndex === normalizedQuery.length) {
// Calculate a score based on closeness of matches
// Higher score if characters are close together
const matchRatio = normalizedStr.length / (strIndex + 1)
return matchRatio
}
return 0
}
/**
* Advanced fuzzy search with multiple scoring methods
* Returns a score from 0-1, where 1 is a perfect match
*/
export function fuzzySearch(text: string, query: string): number {
if (!query) return 1
if (!text) return 0
// Direct inclusion check (highest priority)
const normalizedText = text.toLowerCase()
const normalizedQuery = query.toLowerCase()
if (normalizedText === normalizedQuery) return 1
if (normalizedText.includes(normalizedQuery)) return 0.9
// Check for character sequence matches
const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
// Calculate string similarity
const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
// Word-by-word matching for multi-word queries
const textWords = normalizedText.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
let wordMatchCount = 0
for (const queryWord of queryWords) {
for (const textWord of textWords) {
if (
textWord.includes(queryWord) ||
calculateStringSimilarity(textWord, queryWord) > 0.7 ||
containsCharsInOrder(textWord, queryWord) > 0
) {
wordMatchCount++
break
}
}
}
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
// Combine scores with weights
return Math.max(sequenceScore * 0.3, similarityScore * 0.3, wordMatchScore * 0.4)
}