Merge branch 'main' into feat/ai-categories
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 }}
|
||||||
|
@ -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.
|
||||||
|
140
CONTRIBUTING.md
@ -1,104 +1,104 @@
|
|||||||

|
# 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).
|
||||||
|
184
README.md
@ -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).
|
|
||||||
|
|
||||||
[](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons)
|
[](https://www.jsdelivr.com/package/gh/walkxcode/dashboard-icons)
|
||||||
[](https://www.jsdelivr.com/package/gh/walkxcode/dashboard-icons)
|
[](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons)
|
||||||
|
[](https://github.com/homarr-labs/dashboard-icons/stargazers)
|
||||||
|
[](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 you’d 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
@ -1,11 +0,0 @@
|
|||||||
[](https://www.jsdelivr.com/package/gh/homarr-labs/dashboard-icons)
|
|
||||||
[](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 -->
|
|
@ -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.")
|
|
@ -19,7 +19,10 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
@ -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
BIN
web/public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
web/public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 56 KiB |
BIN
web/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
web/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 620 B |
BIN
web/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB |
@ -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 |
26
web/public/site.webmanifest
Normal 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
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
254
web/src/app/icons/[icon]/opengraph-image.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
@ -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())),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Then filter by search query
|
||||||
|
if (query.trim()) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
return icons.filter(({ name, data }) => {
|
filtered = filtered.filter(({ name, data }) => {
|
||||||
if (name.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.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
|
||||||
if (data.categories.some((category) => category.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 */}
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground transition-all duration-300">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search icons by name, aliases, or categories..."
|
placeholder="Search icons by name, alias, or category..."
|
||||||
className="w-full pl-8"
|
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{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
|
|
||||||
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
|
|
||||||
alt={`${name} icon`}
|
|
||||||
fill
|
|
||||||
className="object-contain p-1 group-hover:scale-110 transition-transform"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs sm:text-sm text-center truncate w-full capitalize">{name.replace(/-/g, " ")}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
23
web/src/app/icons/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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"],
|
|
||||||
|
return {
|
||||||
|
title: "Browse Icons | Free Dashboard Icons",
|
||||||
|
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
|
keywords: [
|
||||||
|
"browse icons",
|
||||||
|
"dashboard icons",
|
||||||
|
"icon search",
|
||||||
|
"service icons",
|
||||||
|
"application icons",
|
||||||
|
"tool icons",
|
||||||
|
"web dashboard",
|
||||||
|
"app directory",
|
||||||
|
],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
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",
|
type: "website",
|
||||||
url: `${BASE_URL}/icons`,
|
url: `${BASE_URL}/icons`,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-image-browse.png",
|
url: "/og-image.png",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Browse Dashboard Icons",
|
alt: "Browse Dashboard Icons Collection",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
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.`,
|
||||||
images: ["/og-image-browse.png"],
|
images: ["/og-image-browse.png"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${BASE_URL}/icons`,
|
canonical: `${BASE_URL}/icons`,
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
@ -38,8 +52,9 @@ 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="isolate overflow-hidden">
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
<div className="space-y-4 mb-8 mx-auto max-w-[80vw]">
|
<div className="space-y-4 mb-8 mx-auto max-w-7xl">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Browse icons</h1>
|
<h1 className="text-3xl font-bold">Browse icons</h1>
|
||||||
@ -50,5 +65,6 @@ export default async function IconsPage() {
|
|||||||
<IconSearch icons={icons} />
|
<IconSearch icons={icons} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,34 +1,38 @@
|
|||||||
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",
|
|
||||||
"dashboard design",
|
|
||||||
],
|
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
@ -41,9 +45,9 @@ export const metadata: Metadata = {
|
|||||||
siteName: "Dashboard Icons",
|
siteName: "Dashboard Icons",
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
title: "Dashboard Icons",
|
title: websiteTitle,
|
||||||
description: "Curated icons for your dashboard",
|
description: getDescription(totalIcons),
|
||||||
url: "https://icons.homarr.dev",
|
url: "https://dashboardicons.com",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-image.png",
|
url: "/og-image.png",
|
||||||
@ -58,8 +62,8 @@ export const metadata: Metadata = {
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
site: "@homarr_app",
|
site: "@homarr_app",
|
||||||
creator: "@homarr_app",
|
creator: "@homarr_app",
|
||||||
title: "Dashboard Icons",
|
title: websiteTitle,
|
||||||
description: "Curated icons for your dashboard",
|
description: getDescription(totalIcons),
|
||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
applicationName: "Dashboard Icons",
|
applicationName: "Dashboard Icons",
|
||||||
@ -68,47 +72,39 @@ export const metadata: Metadata = {
|
|||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
capable: true,
|
capable: true,
|
||||||
},
|
},
|
||||||
alternates: {
|
|
||||||
types: {
|
|
||||||
"application/rss+xml": "https://icons.homarr.dev/rss.xml",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{ url: "/favicon.ico", sizes: "any" },
|
||||||
url: "/favicon.ico",
|
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||||
type: "image/x-icon",
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
},
|
|
||||||
],
|
],
|
||||||
shortcut: [
|
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||||
|
other: [
|
||||||
{
|
{
|
||||||
url: "/favicon.ico",
|
rel: "mask-icon",
|
||||||
type: "image/x-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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
@ -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="flex flex-col items-center">
|
||||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
||||||
<AlertTriangle className="w-8 h-8" />
|
<AlertTriangle className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">Icon not found</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold mt-6">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>
|
<p className="text-muted-foreground mt-3 max-w-md">
|
||||||
<p className="text-muted-foreground">If you believe this is an error, please contact the maintainers of the repository.</p>
|
The icon you are looking for could not be found or there was an error loading it.
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
</p>
|
||||||
<Button asChild>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Button asChild variant="outline">
|
||||||
<Link href="/icons">
|
<Link href="/icons">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to all icons
|
Back to 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>
|
||||||
)
|
)
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
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 {
|
||||||
|
title: websiteTitle,
|
||||||
|
description: getDescription(totalIcons),
|
||||||
|
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
title: websiteTitle,
|
||||||
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
|
description: getDescription(totalIcons),
|
||||||
type: "website",
|
type: "website",
|
||||||
url: BASE_URL,
|
url: BASE_URL,
|
||||||
images: [
|
images: [
|
||||||
@ -22,22 +26,33 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
title: websiteTitle,
|
||||||
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
|
description: getDescription(totalIcons),
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: BASE_URL,
|
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
@ -0,0 +1,3 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://dashboardicons.com/sitemap.xml
|
@ -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[],
|
||||||
})),
|
})),
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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 {
|
||||||
e.preventDefault()
|
setInternalOpen(value)
|
||||||
setOpen((open) => !open)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
|
||||||
return () => document.removeEventListener("keydown", down)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleInputChange = React.useCallback((value: string) => {
|
|
||||||
setInputValue(value)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSelectIcon = React.useCallback(
|
|
||||||
(iconName: string) => {
|
|
||||||
router.push(`/icons/${iconName}`)
|
|
||||||
setOpen(false)
|
|
||||||
},
|
},
|
||||||
[router],
|
[externalOnOpenChange],
|
||||||
)
|
)
|
||||||
if (!mounted) return null
|
|
||||||
|
const filteredIcons = getFilteredIcons(icons, query)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
(e.key === "k" && (e.metaKey || e.ctrlKey)) ||
|
||||||
|
(e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA")
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [isOpen, setIsOpen])
|
||||||
|
|
||||||
|
function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) {
|
||||||
|
if (!query) {
|
||||||
|
// Return a limited number of icons when no query is provided
|
||||||
|
return iconList.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scores for each icon
|
||||||
|
const scoredIcons = iconList.map((icon) => {
|
||||||
|
// Calculate scores for different fields
|
||||||
|
const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches
|
||||||
|
|
||||||
|
// Get max score from aliases
|
||||||
|
const aliasScore =
|
||||||
|
icon.data.aliases && icon.data.aliases.length > 0
|
||||||
|
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * 1.8 // Increased weight for aliases
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// Get max score from categories
|
||||||
|
const categoryScore =
|
||||||
|
icon.data.categories && icon.data.categories.length > 0
|
||||||
|
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query)))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// Use the highest score
|
||||||
|
const score = Math.max(nameScore, aliasScore, categoryScore)
|
||||||
|
|
||||||
|
return { icon, score, matchedField: score === nameScore ? "name" : score === aliasScore ? "alias" : "category" }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter icons with a minimum score and sort by highest score
|
||||||
|
return scoredIcons
|
||||||
|
.filter((item) => item.score > 0.3) // Higher threshold for more accurate results
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 20) // Limit the number of results
|
||||||
|
.map((item) => item.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (name: string) => {
|
||||||
|
setIsOpen(false)
|
||||||
|
router.push(`/icons/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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>
|
||||||
|
</div>
|
||||||
|
<span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span>
|
||||||
|
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
|
||||||
|
{!matchedAlias && data.categories && data.categories.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
||||||
|
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
181
web/src/components/footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface GridBackgroundProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridBackground({ className }: GridBackgroundProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("absolute inset-0 overflow-hidden", className)}>
|
|
||||||
{/* Grid pattern */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0",
|
|
||||||
"[background-size:40px_40px]",
|
|
||||||
"[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
|
|
||||||
"dark:[background-image:linear-gradient(to_right,rgba(99,102,241,0.1)_1px,transparent_1px),linear-gradient(to_bottom,rgba(99,102,241,0.1)_1px,transparent_1px)]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-slate-900 [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-slate-900" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -9,17 +9,23 @@ export function HeaderNav() {
|
|||||||
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
|
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>
|
||||||
|
5
web/src/components/header-wrapper.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Header } from "./header"
|
||||||
|
|
||||||
|
export function HeaderWrapper() {
|
||||||
|
return <Header />
|
||||||
|
}
|
@ -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>
|
||||||
|
<div className="flex-nowrap">
|
||||||
<HeaderNav />
|
<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 */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 cursor-pointer transition-all duration-300"
|
||||||
|
onClick={openCommandMenu}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 transition-all duration-300" />
|
||||||
|
<span>Find icons</span>
|
||||||
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
|
||||||
|
<span className="text-xs">⌘</span>K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile search button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 "
|
||||||
|
onClick={openCommandMenu}
|
||||||
|
>
|
||||||
|
<Search className="h-5 w-5 transition-all duration-300" />
|
||||||
|
<span className="sr-only">Find icons</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-2 md:gap-4">
|
||||||
<IconSubmissionForm />
|
<IconSubmissionForm />
|
||||||
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary">
|
<TooltipProvider>
|
||||||
<Github className="h-5 w-5" />
|
<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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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">
|
|
||||||
<Card className="p-2 flex flex-row items-center gap-2 hover:scale-105 transition-all duration-300">
|
|
||||||
<Circle className="h-2 w-2 fill-rose-500/80" />
|
|
||||||
<span className="text-sm text-foreground/60 tracking-wide">by homarr-labs</span>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<motion.div custom={1} variants={fadeUpVariants} initial="hidden" animate="visible">
|
|
||||||
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold mb-6 md:mb-8 tracking-tight">
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/80">
|
|
||||||
Your definitive source for
|
Your definitive source for
|
||||||
</span>
|
<Sparkles className="absolute -right-1 -bottom-3 text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-[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" />
|
||||||
<br />
|
<br />
|
||||||
<span className={cn("bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-foreground/90 to-rose-300")}>
|
<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" />
|
||||||
dashboard icons.
|
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div custom={2} 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">
|
||||||
<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 <NumberTicker value={totalIcons} className="font-bold tracking-tighter text-muted-foreground" /> curated icons
|
||||||
A collection of {totalIcons} beautiful, clean and consistent icons for your dashboard, application, or website.
|
for services, applications and tools, designed specifically for dashboards and app directories.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
|
||||||
|
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
|
||||||
<motion.div
|
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-2000">
|
||||||
custom={3}
|
<Link href="/icons">
|
||||||
variants={fadeUpVariants}
|
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
|
||||||
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>
|
</Link>
|
||||||
</Button>
|
<GiveUsAStarButton stars={stars} />
|
||||||
<Button variant="outline" size="lg" className="gap-2" asChild>
|
<GiveUsMoneyButton />
|
||||||
<Link href="https://github.com/homarr-labs/dashboard-icons" target="_blank" rel="noopener noreferrer">
|
<GiveUsLoveButton />
|
||||||
GitHub
|
</div>
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -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,56 +90,111 @@ 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">
|
||||||
|
<div className="flex flex-col items-center p-4 transition-all">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative w-28 h-28 mb-3 cursor-pointer rounded-md overflow-hidden group"
|
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => handleCopy(url, variantKey)}
|
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-md z-10 transition-colors" />
|
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-md"
|
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: isCopied ? 1 : 0 }}
|
animate={{ opacity: isCopied ? 1 : 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
animate={{ scale: isCopied ? 1 : 0.5, opacity: isCopied ? 1 : 0 }}
|
animate={{
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
scale: isCopied ? 1 : 0.5,
|
||||||
|
opacity: isCopied ? 1 : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Check className="w-8 h-8 text-primary" />
|
<Check className="w-8 h-8 text-primary" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={url}
|
src={imageUrl}
|
||||||
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-2"
|
className="object-contain p-4"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Click to copy URL to clipboard</p>
|
<p>Click to copy direct URL to clipboard</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@ -113,14 +203,17 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<div className="flex gap-2 mt-3 w-full justify-center">
|
<div className="flex gap-2 mt-3 w-full justify-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8" asChild>
|
<Button
|
||||||
<a href={url} download={`${iconName}.${format}`}>
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
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</p>
|
<p>Download icon file</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@ -129,20 +222,20 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
<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>
|
||||||
@ -154,19 +247,20 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
</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,6 +288,7 @@ 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>
|
||||||
|
{authorData.html_url ? (
|
||||||
<Link
|
<Link
|
||||||
href={authorData.html_url}
|
href={authorData.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -202,36 +297,70 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
|||||||
>
|
>
|
||||||
{authorName}
|
{authorName}
|
||||||
</Link>
|
</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>
|
||||||
|
@ -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>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<span className="font-medium">{template.name}</span>
|
<span className="font-medium transition-all duration-300">{template.name}</span>
|
||||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{template.description}</span>
|
<span className="text-xs text-muted-foreground">{template.description}</span>
|
||||||
|
</div>
|
||||||
</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)} />
|
||||||
|
@ -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"
|
||||||
|
33
web/src/components/magicui/animated-shiny-text.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
37
web/src/components/magicui/aurora-text.tsx
Normal 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"
|
33
web/src/components/magicui/interactive-hover-button.tsx
Normal 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"
|
106
web/src/components/magicui/magic-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
73
web/src/components/magicui/marquee.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
67
web/src/components/magicui/number-ticker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
116
web/src/components/recently-added-icons.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="hover:text-primary" variant="ghost" size="icon">
|
<Button
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
className=" transition-colors duration-200 group hover:ring-2 rounded-lg cursor-pointer"
|
||||||
<Moon className=" absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 group-hover:" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 group-hover:" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>Change theme</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
Light
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
61
web/src/components/ui/aurora-background.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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: {
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -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}
|
||||||
@ -131,5 +131,6 @@ export {
|
|||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
25
web/src/hooks/use-media-query.ts
Normal 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
|
||||||
|
}
|
@ -1,26 +1,58 @@
|
|||||||
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[]> => {
|
||||||
|
try {
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
return Object.keys(iconsData)
|
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[]> {
|
||||||
|
try {
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
|
|
||||||
return Object.entries(iconsData)
|
return Object.entries(iconsData)
|
||||||
@ -29,29 +61,42 @@ export async function getIconsArray(): Promise<IconWithName[]> {
|
|||||||
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> {
|
||||||
|
try {
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
const iconData = iconsData[iconName]
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches author data from GitHub API
|
* Fetches author data from GitHub API
|
||||||
*/
|
*/
|
||||||
export async function getAuthorData(authorId: number) {
|
export async function getAuthorData(authorId: number) {
|
||||||
|
try {
|
||||||
const response = await fetch(`https://api.github.com/user/${authorId}`, {
|
const response = await fetch(`https://api.github.com/user/${authorId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
@ -59,16 +104,67 @@ export async function getAuthorData(authorId: number) {
|
|||||||
},
|
},
|
||||||
next: { revalidate: 86400 }, // Revalidate cache once a day
|
next: { revalidate: 86400 }, // Revalidate cache once a day
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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()
|
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() {
|
||||||
|
try {
|
||||||
const iconsData = await getAllIcons()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|