Merge pull request #1174 from homarr-labs/feat/visual-enhancements
@ -49,8 +49,6 @@ jobs:
|
||||
INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }}
|
||||
- name: Generate File Tree
|
||||
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
|
||||
run: python scripts/generate_metadata.py
|
||||
- name: Extract icon name
|
||||
|
@ -49,8 +49,6 @@ jobs:
|
||||
INPUT_ISSUE_FORM: ${{ steps.parse_issue_form.outputs.ISSUE_FORM }}
|
||||
- name: Generate File Tree
|
||||
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
|
||||
run: python scripts/generate_metadata.py
|
||||
- name: Extract icon name
|
||||
|
@ -77,14 +77,9 @@ jobs:
|
||||
with:
|
||||
python-version: "3.9"
|
||||
|
||||
- name: Generate ICONS.md
|
||||
run: python scripts/generate_icons_page.py
|
||||
|
||||
- name: Commit and Push Changes
|
||||
run: |
|
||||
git config --global user.email "homarr-labs@proton.me"
|
||||
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 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
|
||||
- Deliberate intimidation or stalking
|
||||
- Unwelcome sexual attention or harassment
|
||||
- Inappropriate physical contact
|
||||
- Disruptions during events or conversations
|
||||
- Discrimination of any kind
|
||||
The following behaviors are unacceptable:
|
||||
|
||||
### 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.
|
||||
|
146
CONTRIBUTING.md
@ -1,104 +1,104 @@
|
||||

|
||||
# Contributing to Dashboard Icons
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contribution Guidelines](#contribution-guidelines)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Icon Specifications](#icon-specifications)
|
||||
- [Format](#format)
|
||||
- [Cropping](#cropping)
|
||||
- [Light and Dark Versions](#light-and-dark-versions)
|
||||
- [File Naming](#file-naming)
|
||||
- [Quality Requirements](#quality-requirements)
|
||||
- [Git Commit Messages](#git-commit-messages)
|
||||
- [Contribution Process](#contribution-process)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Contact](#contact)
|
||||
- [Contributing to Dashboard Icons](#contributing-to-dashboard-icons)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Icon Specifications](#icon-specifications)
|
||||
- [Format Requirements](#format-requirements)
|
||||
- [Quality Standards](#quality-standards)
|
||||
- [Light \& Dark Variants](#light--dark-variants)
|
||||
- [File Naming](#file-naming)
|
||||
- [Requesting New Icons](#requesting-new-icons)
|
||||
- [Improving the Repository](#improving-the-repository)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Questions?](#questions)
|
||||
|
||||
## 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.
|
||||
- **Automatic PNG and WEBP Generation**: PNG and WEBP versions are generated automatically from the SVG (or PNG) files using the following settings:
|
||||
- **Dimensions**:
|
||||
- Height: 512 pixels
|
||||
- Width: Auto (maintaining aspect ratio)
|
||||
- **Transparency**: Enabled
|
||||
- **SVG Format**: All icons must be submitted in SVG format
|
||||
- **Auto-Generated Formats**: PNG and WEBP versions are generated automatically with:
|
||||
- Height: 512 pixels
|
||||
- Width: Auto (maintaining aspect ratio)
|
||||
- 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**:
|
||||
- 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.
|
||||
- **`-dark` Version**: For icons primarily light or using white as a main color, provide a `-dark` version for dark backgrounds.
|
||||
- **Examples**:
|
||||
- A black logo should include a `-light` version where black is inverted.
|
||||
- A multicolored logo using black should provide a `-light` version with the black replaced.
|
||||
- **Tool Recommendation**: [DEEditor](https://deeditor.com/) can help adjust icon colors if needed.
|
||||
For monochrome or single-color icons:
|
||||
|
||||
- **Light Variant**: Required for dark backgrounds
|
||||
- Invert black elements
|
||||
- Adjust colors for visibility
|
||||
- **Dark Variant**: Required for light backgrounds
|
||||
- Invert white elements
|
||||
- Adjust colors for visibility
|
||||
|
||||
**Tool Recommendation**: [DEEditor](https://deeditor.com/) for color adjustments
|
||||
|
||||
### File Naming
|
||||
|
||||
- **Kebab Case**: Name your files using kebab case (lowercase words separated by hyphens). For example, "Nextcloud Calendar" becomes `nextcloud-calendar.svg`.
|
||||
- **Note**: Filenames are automatically converted to kebab case, but please double-check your naming to avoid conflicts or errors.
|
||||
- **Kebab Case**: Use lowercase with hyphens
|
||||
- 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.
|
||||
- **No Embedded Raster Images in SVGs**: Ensure that SVG files are true vector graphics without embedded raster images.
|
||||
To request a new icon:
|
||||
|
||||
## 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:
|
||||
- `feat(icons): add nextcloud-calendar` when adding new icons.
|
||||
2. **Provide Information**:
|
||||
- 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):
|
||||
- **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.
|
||||
To contribute to the repository itself:
|
||||
|
||||
### 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:
|
||||
|
||||
1. **Create issue**: Create an issue from the update [template](https://github.com/homarr-labs/dashboard-icons/issues/new/choose).
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
3. **Submit a Pull Request**:
|
||||
- Use semantic commit messages following the format: `<type>(scope): description`
|
||||
- `feat(icons): add nextcloud-calendar`
|
||||
- `fix(website): correct icon preview`
|
||||
- `docs(readme): update installation instructions`
|
||||
- Reference any related issues
|
||||
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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]
|
||||
> 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).
|
||||
# Dashboard Icons
|
||||
|
||||
[](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.
|
||||
[**View icons →**](https://icons.homarr.dev)
|
||||
**[→ Browse the collection at dashboardicons.com](https://dashboardicons.com)**
|
||||
|
||||
## Table of Contents
|
||||
## Why Dashboard Icons?
|
||||
|
||||
- [Dashboard Icons](#dashboard-icons)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Icon Requests](#icon-requests)
|
||||
- [Supported Dashboards](#supported-dashboards)
|
||||
- [Usage and Details](#usage-and-details)
|
||||
- [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)
|
||||
- **Comprehensive Collection**: 1800+ icons for all popular services and tools
|
||||
- **Consistent Style**: Uniform visual language across different services
|
||||
- **Multiple Formats**: Available in SVG, PNG, and WEBP to suit your needs
|
||||
- **Light & Dark Variants**: Icons optimized for both light and dark themes
|
||||
- **Community-Driven**: Easy process to request missing icons
|
||||
|
||||
## 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)
|
||||
- [Homepage](https://github.com/gethomepage/homepage)
|
||||
- [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
|
||||
|
||||
```
|
||||
https://<Base URL>/<Format>/<Name>.<Format>
|
||||
```
|
||||
Need an icon that's not in our collection?
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/nextcloud-calendar.webp
|
||||
```
|
||||
### Improve the Repository
|
||||
|
||||
#### 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:
|
||||
|
||||
- SVG
|
||||
- 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).
|
||||
<p align="center">
|
||||
Made with ♥ by the <a href="https://github.com/homarr-labs">Homarr Labs</a> team and contributors
|
||||
</p>
|
||||
|
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": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "pnpx serve@latest out",
|
||||
"format": "biome check --write",
|
||||
"lint": "biome lint --write",
|
||||
"ci": "biome check --write"
|
||||
@ -39,15 +39,16 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@tanstack/react-virtual": "^3.13.6",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.6.5",
|
||||
"framer-motion": "^12.7.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.487.0",
|
||||
"motion": "^12.6.5",
|
||||
"motion": "^12.7.3",
|
||||
"next": "15.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.235.6",
|
||||
@ -60,6 +61,7 @@
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-motion": "^1.1.0",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
@ -67,14 +69,22 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.12.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.8.0",
|
||||
"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 "tw-animate-css";
|
||||
@plugin "tailwindcss-motion";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--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-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-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--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 {
|
||||
from {
|
||||
@ -61,28 +77,85 @@
|
||||
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 {
|
||||
--radius: 0.3rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.637 0.237 25.331);
|
||||
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--radius: 0.4rem;
|
||||
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0.32 0 0);
|
||||
--card: oklch(1.0 0 0);
|
||||
--card-foreground: oklch(0.32 0 0);
|
||||
--popover: oklch(1.0 0 0);
|
||||
--popover-foreground: oklch(0.32 0 0);
|
||||
--primary: oklch(0.67 0.2 23.8);
|
||||
--primary-foreground: oklch(1.0 0 0);
|
||||
--secondary: oklch(0.97 0.0 264.54);
|
||||
--secondary-foreground: oklch(0.45 0.03 256.8);
|
||||
--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-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--destructive: oklch(0.64 0.21 25.33);
|
||||
--destructive-foreground: oklch(1.0 0 0);
|
||||
--border: oklch(0.9 0.01 247.88);
|
||||
|
||||
--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-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@ -96,25 +169,41 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--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 {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.637 0.237 25.331);
|
||||
--primary-foreground: oklch(0.971 0.013 17.38);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--foreground: oklch(0.92 0 0);
|
||||
--card: oklch(0.31 0.03 268.64);
|
||||
--card-foreground: oklch(0.92 0 0);
|
||||
--popover: oklch(0.29 0.02 268.4);
|
||||
--popover-foreground: oklch(0.92 0 0);
|
||||
--primary: oklch(0.67 0.2 23.8);
|
||||
--primary-foreground: oklch(1.0 0 0);
|
||||
--secondary: oklch(0.31 0.03 266.71);
|
||||
--secondary-foreground: oklch(0.92 0 0);
|
||||
--muted: oklch(0.31 0.03 266.71);
|
||||
--muted-foreground: oklch(0.72 0 0);
|
||||
--accent: oklch(0.34 0.06 267.59);
|
||||
--accent-foreground: oklch(0.88 0.06 254.13);
|
||||
--destructive: oklch(0.64 0.21 25.33);
|
||||
--destructive-foreground: oklch(1.0 0 0);
|
||||
--border: oklch(0.38 0.03 269.73);
|
||||
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.637 0.237 25.331);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
@ -130,6 +219,20 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--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 {
|
||||
@ -140,3 +243,21 @@
|
||||
@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 { BASE_URL } from "@/constants"
|
||||
import { BASE_URL, WEB_URL } from "@/constants"
|
||||
import { getAllIcons, getAuthorData } from "@/lib/api"
|
||||
import type { Metadata, ResolvingMetadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
@ -26,20 +26,32 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||
if (!iconsData[icon]) {
|
||||
notFound()
|
||||
}
|
||||
const previousImages = (await parent).openGraph?.images || []
|
||||
const authorData = await getAuthorData(iconsData[icon].update.author.id)
|
||||
const authorName = authorData.name || authorData.login
|
||||
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()}`)
|
||||
|
||||
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 {
|
||||
title: `${icon} icon · Dashboard Icons`,
|
||||
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
|
||||
keywords: [`${icon} icon`, "dashboard icon", "free icon", "open source icon", "application icon"],
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
keywords: [
|
||||
`${formattedIconName} icon`,
|
||||
"dashboard icon",
|
||||
"service icon",
|
||||
"application icon",
|
||||
"tool icon",
|
||||
"web dashboard",
|
||||
"app directory",
|
||||
],
|
||||
authors: [
|
||||
{
|
||||
name: "homarr",
|
||||
@ -51,30 +63,20 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||
},
|
||||
],
|
||||
openGraph: {
|
||||
title: `${icon} icon · Dashboard Icons`,
|
||||
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
type: "article",
|
||||
url: pageUrl,
|
||||
images: [
|
||||
{
|
||||
url: iconImageUrl,
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: `${icon} icon`,
|
||||
type: "image/png",
|
||||
},
|
||||
...previousImages,
|
||||
],
|
||||
authors: [authorName, "homarr"],
|
||||
publishedTime: updateDate.toISOString(),
|
||||
modifiedTime: updateDate.toISOString(),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${icon} icon · Dashboard Icons`,
|
||||
description: `Download and use the ${icon} icon from Dashboard Icons for your applications`,
|
||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
images: [iconImageUrl],
|
||||
creator: "@ajnavocado",
|
||||
creator: "@homarr_app",
|
||||
},
|
||||
alternates: {
|
||||
canonical: pageUrl,
|
||||
@ -91,9 +93,7 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Pass originalIconData directly, assuming IconDetails can handle it
|
||||
const iconData = originalIconData
|
||||
|
||||
const authorData = await getAuthorData(originalIconData.update.author.id)
|
||||
|
||||
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
|
||||
}
|
||||
|
@ -46,11 +46,11 @@ export function IconSearch({ icons, initialQuery = "" }: IconSearchProps) {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
type="search"
|
||||
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}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
|
@ -1,63 +1,144 @@
|
||||
"use client"
|
||||
|
||||
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 { Separator } from "@/components/ui/separator"
|
||||
import { BASE_URL } from "@/constants"
|
||||
import type { IconSearchProps } from "@/types/icons"
|
||||
import { Search } from "lucide-react"
|
||||
import type { Icon, IconSearchProps } from "@/types/icons"
|
||||
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
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) {
|
||||
const searchParams = useSearchParams()
|
||||
const initialQuery = searchParams.get("q")
|
||||
const initialCategories = searchParams.getAll("category")
|
||||
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
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 [filteredIcons, setFilteredIcons] = useState(() => {
|
||||
if (!initialQuery?.trim()) return icons
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const q = initialQuery.toLowerCase()
|
||||
return icons.filter(({ name, data }) => {
|
||||
if (name.toLowerCase().includes(q)) return true
|
||||
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
|
||||
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
|
||||
// Extract all unique categories
|
||||
const allCategories = useMemo(() => {
|
||||
const categories = new Set<string>()
|
||||
for (const icon of icons) {
|
||||
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(
|
||||
(query: string) => {
|
||||
if (!query.trim()) {
|
||||
return icons
|
||||
(query: string, categories: string[], sort: SortOption) => {
|
||||
// First filter by categories if any are selected
|
||||
let filtered = icons
|
||||
if (categories.length > 0) {
|
||||
filtered = filtered.filter(({ data }) =>
|
||||
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
|
||||
)
|
||||
}
|
||||
|
||||
const q = query.toLowerCase()
|
||||
return icons.filter(({ name, data }) => {
|
||||
if (name.toLowerCase().includes(q)) return true
|
||||
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
|
||||
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
|
||||
// Then filter by search query
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase()
|
||||
filtered = filtered.filter(({ name, data }) => {
|
||||
if (name.toLowerCase().includes(q)) return true
|
||||
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
|
||||
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
// 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(
|
||||
(query: string) => {
|
||||
setFilteredIcons(filterIcons(query))
|
||||
(query: string, categories: string[], sort: SortOption) => {
|
||||
const params = new URLSearchParams()
|
||||
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 })
|
||||
},
|
||||
[filterIcons, pathname, router],
|
||||
[pathname, router, initialSort],
|
||||
)
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
setSearchQuery(query)
|
||||
@ -65,11 +146,45 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
updateResults(query)
|
||||
}, 100)
|
||||
updateResults(query, selectedCategories, sortOption)
|
||||
}, 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(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
@ -80,48 +195,258 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="relative w-full sm:max-w-md">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search icons by name, aliases, or categories..."
|
||||
className="w-full pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-4 w-full">
|
||||
{/* 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
|
||||
type="search"
|
||||
placeholder="Search icons by name, alias, or category..."
|
||||
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter and sort controls */}
|
||||
<div className="flex flex-wrap gap-2 justify-start">
|
||||
{/* Filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm ">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<span>Filter</span>
|
||||
{selectedCategories.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 px-1.5">
|
||||
{selectedCategories.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 sm:w-56">
|
||||
<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto p-1">
|
||||
{allCategories.map((category) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={category}
|
||||
checked={selectedCategories.includes(category)}
|
||||
onCheckedChange={() => handleCategoryChange(category)}
|
||||
className="cursor-pointer capitalize"
|
||||
>
|
||||
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedCategories.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedCategories([])
|
||||
updateResults(searchQuery, [], sortOption)
|
||||
}}
|
||||
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
|
||||
>
|
||||
Clear all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
|
||||
{getSortIcon(sortOption)}
|
||||
<span className="ml-2">{getSortLabel(sortOption)}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
|
||||
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Best match
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
|
||||
<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
|
||||
<ArrowUpZA className="h-4 w-4 mr-2" />Z to A
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Newest first
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Clear all button */}
|
||||
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
<span>Clear all</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter badges */}
|
||||
{selectedCategories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-sm text-muted-foreground">Filters:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCategories.map((category) => (
|
||||
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
|
||||
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer"
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedCategories([])
|
||||
updateResults(searchQuery, [], sortOption)
|
||||
}}
|
||||
className="text-xs h-7 px-2 cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-2" />
|
||||
</div>
|
||||
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto">
|
||||
<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>
|
||||
<IconSubmissionContent />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-8">
|
||||
{filteredIcons.map(({ name, data }) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={name}
|
||||
href={`/icons/${name}`}
|
||||
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="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 group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs sm:text-sm text-center truncate w-full capitalize">{name.replace(/-/g, " ")}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Found {filteredIcons.length} icon
|
||||
{filteredIcons.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{getSortIcon(sortOption)}
|
||||
<span>{getSortLabel(sortOption)}</span>
|
||||
</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 { IconSearch } from "./components/icon-search"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Browse icons | Dashboard Icons",
|
||||
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
|
||||
keywords: ["dashboard icons", "browse icons", "icon search", "free icons", "open source icons"],
|
||||
openGraph: {
|
||||
title: "Browse Dashboard Icons Collection",
|
||||
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
|
||||
type: "website",
|
||||
url: `${BASE_URL}/icons`,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image-browse.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Browse Dashboard Icons",
|
||||
type: "image/png",
|
||||
},
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const icons = await getIconsArray()
|
||||
const totalIcons = icons.length
|
||||
|
||||
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",
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Browse Dashboard Icons Collection",
|
||||
description: "Search and browse through our collection of beautiful dashboard icons for your applications",
|
||||
images: ["/og-image-browse.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/icons`,
|
||||
},
|
||||
openGraph: {
|
||||
title: "Browse Icons | Free Dashboard Icons",
|
||||
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
type: "website",
|
||||
url: `${BASE_URL}/icons`,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Browse Dashboard Icons Collection",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Browse Icons | Free Dashboard Icons",
|
||||
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||
images: ["/og-image-browse.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/icons`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-static"
|
||||
@ -38,16 +52,18 @@ export const dynamic = "force-static"
|
||||
export default async function IconsPage() {
|
||||
const icons = await getIconsArray()
|
||||
return (
|
||||
<div className="py-8">
|
||||
<div className="space-y-4 mb-8 mx-auto max-w-[80vw]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Browse icons</h1>
|
||||
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
|
||||
<div className="isolate overflow-hidden">
|
||||
<div className="py-8">
|
||||
<div className="space-y-4 mb-8 mx-auto max-w-7xl">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Browse icons</h1>
|
||||
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconSearch icons={icons} />
|
||||
<IconSearch icons={icons} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,114 +1,110 @@
|
||||
import { PostHogProvider } from "@/components/PostHogProvider";
|
||||
import { Header } from "@/components/header";
|
||||
import { LicenseNotice } from "@/components/license-notice";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { PostHogProvider } from "@/components/PostHogProvider"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { HeaderWrapper } from "@/components/header-wrapper"
|
||||
import { LicenseNotice } from "@/components/license-notice"
|
||||
import { getTotalIcons } from "@/lib/api"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { Toaster } from "sonner"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "./theme-provider"
|
||||
import { getDescription, websiteTitle } from "@/constants"
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
minimumScale: 1,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
themeColor: "#ffffff",
|
||||
};
|
||||
viewportFit: "cover",
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://icons.homarr.dev"),
|
||||
title: "Dashboard Icons",
|
||||
description: "Curated icons for your dashboard",
|
||||
keywords: [
|
||||
"dashboard",
|
||||
"icons",
|
||||
"open source",
|
||||
"free icons",
|
||||
"dashboard design",
|
||||
],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
googleBot: "index, follow",
|
||||
},
|
||||
openGraph: {
|
||||
siteName: "Dashboard Icons",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
title: "Dashboard Icons",
|
||||
description: "Curated icons for your dashboard",
|
||||
url: "https://icons.homarr.dev",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@homarr_app",
|
||||
creator: "@homarr_app",
|
||||
title: "Dashboard Icons",
|
||||
description: "Curated icons for your dashboard",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
applicationName: "Dashboard Icons",
|
||||
appleWebApp: {
|
||||
title: "Dashboard Icons",
|
||||
statusBarStyle: "default",
|
||||
capable: true,
|
||||
},
|
||||
alternates: {
|
||||
types: {
|
||||
"application/rss+xml": "https://icons.homarr.dev/rss.xml",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
|
||||
return {
|
||||
metadataBase: new URL("https://dashboardicons.com"),
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
googleBot: "index, follow",
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: "/favicon.ico",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
],
|
||||
shortcut: [
|
||||
{
|
||||
url: "/favicon.ico",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
openGraph: {
|
||||
siteName: "Dashboard Icons",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
url: "https://dashboardicons.com",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@homarr_app",
|
||||
creator: "@homarr_app",
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
applicationName: "Dashboard Icons",
|
||||
appleWebApp: {
|
||||
title: "Dashboard Icons",
|
||||
statusBarStyle: "default",
|
||||
capable: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
other: [
|
||||
{
|
||||
rel: "mask-icon",
|
||||
url: "/safari-pinned-tab.svg",
|
||||
color: "#000000",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<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>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<HeaderWrapper />
|
||||
<main className="flex-grow">{children}</main>
|
||||
<Footer />
|
||||
<Toaster />
|
||||
<LicenseNotice />
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AlertTriangle, ArrowLeft } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, PlusCircle } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function NotFound({
|
||||
@ -9,21 +10,38 @@ export default function NotFound({
|
||||
}) {
|
||||
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 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">
|
||||
<AlertTriangle className="w-8 h-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Icon not found</h1>
|
||||
<p className="text-muted-foreground mt-3 max-w-md">
|
||||
The icon you are looking for could not be found or there was an error loading it.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Icon not found</h1>
|
||||
<p className="text-muted-foreground">The icon you are looking for could not be found or there was an error loading it.</p>
|
||||
<p className="text-muted-foreground">If you believe this is an error, please contact the maintainers of the repository.</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<Button asChild>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/icons">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to all icons
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-8 mt-8">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-semibold">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>
|
||||
)
|
||||
|
@ -1,43 +1,58 @@
|
||||
import { HeroSection } from "@/components/hero"
|
||||
import { BASE_URL } from "@/constants"
|
||||
import { getTotalIcons } from "@/lib/api"
|
||||
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
|
||||
import { BASE_URL, getDescription, REPO_NAME, websiteTitle } from "@/constants"
|
||||
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard Icons - Beautiful icons for your dashboard",
|
||||
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"],
|
||||
openGraph: {
|
||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
||||
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
|
||||
type: "website",
|
||||
url: BASE_URL,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: "Dashboard Icons - Your definitive source for dashboard icons",
|
||||
description: "Free, open-source icons for your dashboard. Choose from thousands of high-quality icons.",
|
||||
card: "summary_large_image",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
},
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
|
||||
return {
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||
openGraph: {
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
type: "website",
|
||||
url: BASE_URL,
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Dashboard Icons",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
title: websiteTitle,
|
||||
description: getDescription(totalIcons),
|
||||
card: "summary_large_image",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getGitHubStars() {
|
||||
const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`)
|
||||
const data = await response.json()
|
||||
console.log(`GitHub stars: ${data.stargazers_count}`)
|
||||
return data.stargazers_count
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const { totalIcons } = await getTotalIcons()
|
||||
const recentIcons = await getRecentlyAddedIcons(10)
|
||||
const stars = await getGitHubStars()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<HeroSection totalIcons={totalIcons} />
|
||||
<HeroSection totalIcons={totalIcons} stars={stars} />
|
||||
<RecentlyAddedIcons icons={recentIcons} />
|
||||
</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 { getAllIcons } from "@/lib/api";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { BASE_URL, WEB_URL } from "@/constants"
|
||||
import { getAllIcons } from "@/lib/api"
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const dynamic = "force-static"
|
||||
|
||||
// Helper function to format dates as YYYY-MM-DD
|
||||
const formatDate = (date: Date): string => {
|
||||
// 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> {
|
||||
const iconsData = await getAllIcons();
|
||||
const iconsData = await getAllIcons()
|
||||
return [
|
||||
{
|
||||
url: WEB_URL,
|
||||
@ -34,11 +34,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
images: [
|
||||
`${BASE_URL}/png/${iconName}.png`,
|
||||
// SVG is conditional if it exists
|
||||
iconsData[iconName].base === "svg"
|
||||
? `${BASE_URL}/svg/${iconName}.svg`
|
||||
: null,
|
||||
iconsData[iconName].base === "svg" ? `${BASE_URL}/svg/${iconName}.svg` : null,
|
||||
`${BASE_URL}/webp/${iconName}.webp`,
|
||||
].filter(Boolean) as string[],
|
||||
})),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ export function Carbon() {
|
||||
}
|
||||
`}
|
||||
</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>
|
||||
</>
|
||||
|
@ -1,90 +1,138 @@
|
||||
"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 * as React from "react"
|
||||
|
||||
import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
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 [open, setOpen] = React.useState(false)
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
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 [internalOpen, setInternalOpen] = useState(false)
|
||||
const [query, setQuery] = useState("")
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
const filteredIcons = getFilteredIcons()
|
||||
// Use either external or internal state for controlling open state
|
||||
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
// Wrap setIsOpen in useCallback to fix dependency issue
|
||||
const setIsOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (externalOnOpenChange) {
|
||||
externalOnOpenChange(value)
|
||||
} else {
|
||||
setInternalOpen(value)
|
||||
}
|
||||
},
|
||||
[externalOnOpenChange],
|
||||
)
|
||||
|
||||
const filteredIcons = getFilteredIcons(icons, query)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key === "k" && (e.metaKey || e.ctrlKey)) ||
|
||||
(e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA")
|
||||
) {
|
||||
e.preventDefault()
|
||||
setOpen((open) => !open)
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => document.removeEventListener("keydown", down)
|
||||
}, [])
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isOpen, setIsOpen])
|
||||
|
||||
const handleInputChange = React.useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
}, [])
|
||||
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)
|
||||
}
|
||||
|
||||
const handleSelectIcon = React.useCallback(
|
||||
(iconName: string) => {
|
||||
router.push(`/icons/${iconName}`)
|
||||
setOpen(false)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
if (!mounted) return null
|
||||
// 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 (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Press{" "}
|
||||
<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">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>{" "}
|
||||
to search
|
||||
</p>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Type to search icons..." value={inputValue} onValueChange={handleInputChange} />
|
||||
<CommandList className="max-h-[300px]">
|
||||
{filteredIcons.length === 0 && <CommandEmpty>No results found. Try a different search term.</CommandEmpty>}
|
||||
{filteredIcons.map((icon) => (
|
||||
<CommandItem key={icon} onSelect={() => handleSelectIcon(icon)}>
|
||||
<Link prefetch={filteredIcons.length < 3} href={`/icons/${icon}`} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary-foreground" />
|
||||
<span className="capitalize">{icon.replace(/-/g, " ")}</span>
|
||||
</Link>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
<CommandDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty>
|
||||
<CommandGroup heading="Icons">
|
||||
{filteredIcons.map(({ name, data }) => {
|
||||
// Find matched alias for display if available
|
||||
const matchedAlias =
|
||||
query && data.aliases && data.aliases.length > 0
|
||||
? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase()))
|
||||
: null
|
||||
|
||||
return (
|
||||
<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="flex-shrink-0 h-5 w-5 relative">
|
||||
<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
|
||||
<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span>
|
||||
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
|
||||
{!matchedAlias && data.categories && data.categories.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
||||
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
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/")
|
||||
|
||||
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
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
prefetch
|
||||
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
|
||||
</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 { ThemeSwitcher } from "@/components/theme-switcher"
|
||||
import { REPO_PATH } from "@/constants"
|
||||
import { getAllIcons } from "@/lib/api"
|
||||
import { Github } from "lucide-react"
|
||||
import { getIconsArray } from "@/lib/api"
|
||||
import type { IconWithName } from "@/types/icons"
|
||||
import { motion } from "framer-motion"
|
||||
import { Github, Search } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CommandMenu } from "./command-menu"
|
||||
import { HeaderNav } from "./header-nav"
|
||||
import { Button } from "./ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
|
||||
|
||||
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 (
|
||||
<header className="border-b">
|
||||
<div className="px-4 md:px-12 flex items-center justify-between h-16">
|
||||
<motion.header
|
||||
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">
|
||||
<Link href="/" className="text-lg md:text-xl font-bold">
|
||||
Dashboard Icons
|
||||
<Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
|
||||
<span className="transition-colors duration-300 group-hover:">Dashboard Icons</span>
|
||||
</Link>
|
||||
<HeaderNav />
|
||||
<div className="flex-nowrap">
|
||||
<HeaderNav />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<CommandMenu icons={Object.keys(icons)} />
|
||||
<IconSubmissionForm />
|
||||
<Link href={REPO_PATH} target="_blank" className="text-sm font-medium transition-colors hover:text-primary">
|
||||
<Github className="h-5 w-5" />
|
||||
</Link>
|
||||
{/* Desktop search button */}
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 cursor-pointer transition-all duration-300"
|
||||
onClick={openCommandMenu}
|
||||
>
|
||||
<Search className="h-4 w-4 transition-all duration-300" />
|
||||
<span>Find icons</span>
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile search button */}
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 "
|
||||
onClick={openCommandMenu}
|
||||
>
|
||||
<Search className="h-5 w-5 transition-all duration-300" />
|
||||
<span className="sr-only">Find icons</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 md:gap-4">
|
||||
<IconSubmissionForm />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2"
|
||||
asChild
|
||||
>
|
||||
<Link href={REPO_PATH} target="_blank" className="group">
|
||||
<Github className="h-5 w-5 group-hover: transition-all duration-300" />
|
||||
<span className="sr-only">View on GitHub</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>View on GitHub</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<ThemeSwitcher />
|
||||
</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 { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { motion } from "framer-motion"
|
||||
import { Circle, Github, Search } from "lucide-react"
|
||||
import { Separator } from "@radix-ui/react-dropdown-menu"
|
||||
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 { 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 {
|
||||
name: string
|
||||
@ -16,7 +37,7 @@ interface IconCardProps {
|
||||
|
||||
function IconCard({ name, imageUrl }: IconCardProps) {
|
||||
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">
|
||||
<img src={imageUrl} alt={name} className="max-w-full max-h-full" />
|
||||
</div>
|
||||
@ -31,7 +52,9 @@ function ElegantShape({
|
||||
width = 400,
|
||||
height = 100,
|
||||
rotate = 0,
|
||||
gradient = "from-background/[0.1]",
|
||||
gradient = "from-rose-500/[0.5]",
|
||||
mobileWidth,
|
||||
mobileHeight,
|
||||
}: {
|
||||
className?: string
|
||||
delay?: number
|
||||
@ -39,51 +62,78 @@ function ElegantShape({
|
||||
height?: number
|
||||
rotate?: number
|
||||
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 (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -150,
|
||||
rotate: rotate - 15,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
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)}
|
||||
animate={controls}
|
||||
className={cn("absolute will-change-transform", className)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [0, 15, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 12,
|
||||
duration: 8 + Math.random() * 4, // Random duration between 8-12s for varied movement
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
width: isMobile && mobileWidth ? mobileWidth : width,
|
||||
height: isMobile && mobileHeight ? mobileHeight : height,
|
||||
}}
|
||||
className="relative"
|
||||
className="relative will-change-transform"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"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,
|
||||
"backdrop-blur-[2px] border-2 border-white/[0.15]",
|
||||
"shadow-[0_8px_32px_0_rgba(255,255,255,0.1)]",
|
||||
"backdrop-blur-[3px]",
|
||||
"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: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>
|
||||
@ -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 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 (
|
||||
<div className="relative pt-40 w-full flex items-center justify-center overflow-hidden bg-background">
|
||||
<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="relative w-full flex items-center justify-center overflow-hidden">
|
||||
<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
|
||||
delay={0.3}
|
||||
width={600}
|
||||
height={140}
|
||||
mobileWidth={300}
|
||||
mobileHeight={80}
|
||||
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%]"
|
||||
/>
|
||||
|
||||
@ -125,8 +164,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
||||
delay={0.5}
|
||||
width={500}
|
||||
height={120}
|
||||
mobileWidth={250}
|
||||
mobileHeight={70}
|
||||
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%]"
|
||||
/>
|
||||
|
||||
@ -134,8 +175,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
||||
delay={0.4}
|
||||
width={300}
|
||||
height={80}
|
||||
mobileWidth={150}
|
||||
mobileHeight={50}
|
||||
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%]"
|
||||
/>
|
||||
|
||||
@ -143,8 +186,10 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
||||
delay={0.6}
|
||||
width={200}
|
||||
height={60}
|
||||
mobileWidth={100}
|
||||
mobileHeight={40}
|
||||
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%]"
|
||||
/>
|
||||
|
||||
@ -152,73 +197,39 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
||||
delay={0.7}
|
||||
width={150}
|
||||
height={40}
|
||||
mobileWidth={80}
|
||||
mobileHeight={30}
|
||||
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%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 container mx-auto px-4 md:px-6">
|
||||
<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">
|
||||
<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>
|
||||
<div className="relative z-10 container mx-auto px-4 md:px-6 mt-4 py-20">
|
||||
<div className="max-w-4xl mx-auto text-center flex flex-col gap-4 ">
|
||||
<h1 className="relative text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight motion-preset-slide-up motion-duration-2000 ">
|
||||
Your definitive source for
|
||||
<Sparkles className="absolute -right-1 -bottom-3 text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-[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 />
|
||||
<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" />
|
||||
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
||||
</h1>
|
||||
|
||||
<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
|
||||
</span>
|
||||
<br />
|
||||
<span className={cn("bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-foreground/90 to-rose-300")}>
|
||||
dashboard icons.
|
||||
</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div custom={2} variants={fadeUpVariants} initial="hidden" animate="visible">
|
||||
<p className="text-base sm:text-lg md:text-xl text-muted-foreground mb-8 leading-relaxed font-light tracking-wide max-w-2xl mx-auto px-4">
|
||||
A collection of {totalIcons} beautiful, clean and consistent icons for your dashboard, application, or website.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
custom={3}
|
||||
variants={fadeUpVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col items-center gap-6 mb-12"
|
||||
>
|
||||
<form action="/icons" method="GET" className="relative w-full max-w-md">
|
||||
<Input
|
||||
name="q"
|
||||
type="search"
|
||||
placeholder={`Search ${totalIcons} icons...`}
|
||||
className="pl-10 h-12 rounded-lg"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
</form>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="default" className="rounded-lg" size="lg" asChild>
|
||||
<Link href="/icons" className="flex items-center">
|
||||
Browse all icons
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="gap-2" asChild>
|
||||
<Link href="https://github.com/homarr-labs/dashboard-icons" target="_blank" rel="noopener noreferrer">
|
||||
GitHub
|
||||
<Github className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<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">
|
||||
A collection of <NumberTicker value={totalIcons} className="font-bold tracking-tighter text-muted-foreground" /> curated icons
|
||||
for services, applications and tools, designed specifically for dashboards and app directories.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
|
||||
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
|
||||
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-2000">
|
||||
<Link href="/icons">
|
||||
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
|
||||
</Link>
|
||||
<GiveUsAStarButton stars={stars} />
|
||||
<GiveUsMoneyButton />
|
||||
<GiveUsLoveButton />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -226,3 +237,252 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
|
||||
</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 { BASE_URL, REPO_PATH } from "@/constants"
|
||||
import type { AuthorData, Icon } from "@/types/icons"
|
||||
import confetti from "canvas-confetti"
|
||||
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 Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Carbon } from "./carbon"
|
||||
import { MagicCard } from "./magicui/magic-card"
|
||||
import { Badge } from "./ui/badge"
|
||||
|
||||
export type IconDetailsProps = {
|
||||
icon: string
|
||||
@ -42,7 +46,38 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
const availableFormats = getAvailableFormats()
|
||||
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)
|
||||
setCopiedVariants((prev) => ({
|
||||
...prev,
|
||||
@ -55,118 +90,177 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
}))
|
||||
}, 2000)
|
||||
|
||||
// Launch confetti from click position or center of screen
|
||||
if (event) {
|
||||
launchConfetti(event.clientX, event.clientY)
|
||||
} else {
|
||||
launchConfetti()
|
||||
}
|
||||
|
||||
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 { resolvedTheme } = useTheme()
|
||||
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 variantKey = `${format}-${theme || "default"}`
|
||||
const isCopied = copiedVariants[variantKey] || false
|
||||
|
||||
return (
|
||||
<TooltipProvider key={variantKey}>
|
||||
<div className="flex flex-col items-center bg-card rounded-lg p-4 border shadow-sm hover:shadow-md transition-all">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
className="relative w-28 h-28 mb-3 cursor-pointer rounded-md overflow-hidden group"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleCopy(url, variantKey)}
|
||||
>
|
||||
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-md z-10 transition-colors" />
|
||||
|
||||
<TooltipProvider key={variantKey} delayDuration={500}>
|
||||
<MagicCard gradientColor={resolvedTheme === "dark" ? "#262626" : "#D9D9D955"} className="p-0 rounded-md">
|
||||
<div className="flex flex-col items-center p-4 transition-all">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isCopied ? 1 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
|
||||
>
|
||||
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: isCopied ? 1 : 0.5, opacity: isCopied ? 1 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isCopied ? 1 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Check className="w-8 h-8 text-primary" />
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{
|
||||
scale: isCopied ? 1 : 0.5,
|
||||
opacity: isCopied ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
<Check className="w-8 h-8 text-primary" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||
fill
|
||||
className="object-contain p-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Click to copy direct URL to clipboard</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Click to copy URL to clipboard</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<p className="text-sm font-medium">{format.toUpperCase()}</p>
|
||||
|
||||
<p className="text-sm font-medium">{format.toUpperCase()}</p>
|
||||
|
||||
<div className="flex gap-2 mt-3 w-full justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" asChild>
|
||||
<a href={url} download={`${iconName}.${format}`}>
|
||||
<div className="flex gap-2 mt-3 w-full justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
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" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download icon</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download icon file</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={() => handleCopy(url, `btn-${variantKey}`)}
|
||||
>
|
||||
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy URL to clipboard</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg cursor-pointer"
|
||||
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" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy direct URL to clipboard</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>View on GitHub</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
|
||||
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>View on GitHub</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MagicCard>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Left Column: Icon Info and Author */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<Card className="h-full bg-background/50 border shadow-lg">
|
||||
<CardHeader className="pb-4">
|
||||
<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
|
||||
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
||||
width={96}
|
||||
@ -179,8 +273,8 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm">
|
||||
@ -194,44 +288,79 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
<AvatarImage src={authorData.avatar_url} alt={authorName} />
|
||||
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Link
|
||||
href={authorData.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
{authorData.html_url ? (
|
||||
<Link
|
||||
href={authorData.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm">{authorName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{iconData.categories && iconData.categories.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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">
|
||||
{category}
|
||||
</span>
|
||||
<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer">
|
||||
<Badge
|
||||
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>
|
||||
)}
|
||||
|
||||
{iconData.aliases && iconData.aliases.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<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) => (
|
||||
<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}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -239,7 +368,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
{/* Middle Column: Icon variants */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<Card className="h-full bg-background/50 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Icon variants</CardTitle>
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Sun className="w-4 h-4 text-amber-500" />
|
||||
Light theme
|
||||
</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"))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Moon className="w-4 h-4 text-indigo-500" />
|
||||
Dark theme
|
||||
</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"))}
|
||||
</div>
|
||||
</div>
|
||||
@ -277,25 +406,25 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
|
||||
{/* Right Column: Technical details */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<Card className="h-full bg-background/50 border shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Technical details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-primary/80" />
|
||||
<div className="px-3 py-1.5 bg-muted rounded-md text-sm font-medium">{iconData.base.toUpperCase()}</div>
|
||||
<FileType className="w-4 h-4 text-blue-500" />
|
||||
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">{iconData.base.toUpperCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableFormats.map((format) => (
|
||||
<div key={format} className="px-3 py-1.5 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()}
|
||||
</div>
|
||||
))}
|
||||
@ -303,28 +432,24 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||
</div>
|
||||
|
||||
{iconData.colors && (
|
||||
<div className="space-y-3">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(iconData.colors).map(([theme, variant]) => (
|
||||
<div key={theme} className="flex items-center gap-2">
|
||||
<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>
|
||||
<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 className="space-y-3">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link
|
||||
href={`${REPO_PATH}/tree/main/${iconData.base}/${icon}.${iconData.base}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
View on GitHub
|
||||
</Link>
|
||||
|
@ -12,31 +12,31 @@ export const ISSUE_TEMPLATES = [
|
||||
{
|
||||
id: "add_monochrome_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`,
|
||||
},
|
||||
{
|
||||
id: "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`,
|
||||
},
|
||||
{
|
||||
id: "update_monochrome_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`,
|
||||
},
|
||||
{
|
||||
id: "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`,
|
||||
},
|
||||
{
|
||||
id: "blank_issue",
|
||||
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`,
|
||||
},
|
||||
]
|
||||
@ -45,17 +45,20 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{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
|
||||
variant="secondary"
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer"
|
||||
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer transition-all duration-300"
|
||||
asChild
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="font-medium transition-all duration-300">{template.name}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{template.description}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{template.description}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
@ -69,14 +72,17 @@ export function IconSubmissionForm() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="hidden md:inline-flex">
|
||||
<PlusCircle className="h-4 w-4" /> Suggest new icon
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden md:inline-flex cursor-pointer transition-all duration-300"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Contribute new icon
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="md:max-w-4xl backdrop-blur-2xl">
|
||||
<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Suggest a new icon</DialogTitle>
|
||||
<DialogDescription>You can suggest a new icon by creating an issue on GitHub using one of the templates below.</DialogDescription>
|
||||
<DialogTitle>Contribute a new icon</DialogTitle>
|
||||
<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<IconSubmissionContent onClose={() => setOpen(false)} />
|
||||
|
@ -38,11 +38,11 @@ export function LicenseNotice() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Unless otherwise indicated, all images and assets are the property of their respective owners and used for identification
|
||||
purposes only.
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Read the{" "}
|
||||
View our{" "}
|
||||
<Link
|
||||
href={`${REPO_PATH}/blob/main/LICENSE`}
|
||||
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 { 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() {
|
||||
const { setTheme } = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="hover:text-primary" variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className=" absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className=" transition-colors duration-200 group hover:ring-2 rounded-lg cursor-pointer"
|
||||
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>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Change theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
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 { cn } from "@/lib/utils"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
@ -54,7 +54,7 @@ function AlertDialogContent({
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -143,15 +143,7 @@ function AlertDialogCancel({
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialog, AlertDialogAction,
|
||||
AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger
|
||||
}
|
||||
|
||||
|
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 { 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"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
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:
|
||||
"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:
|
||||
"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:
|
||||
"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:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
default: "h-10 px-5 py-2 has-[>svg]:px-3",
|
||||
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
@ -171,7 +171,7 @@ function ChartTooltipContent({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
@ -340,9 +340,9 @@ function getPayloadConfigFromPayload(
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer, ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle, ChartTooltip,
|
||||
ChartTooltipContent
|
||||
ChartContainer, ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle, ChartTooltip,
|
||||
ChartTooltipContent
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -57,7 +57,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -122,14 +122,15 @@ function DialogDescription({
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
}
|
||||
|
||||
|
@ -56,7 +56,7 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
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=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",
|
||||
@ -119,14 +119,7 @@ function DrawerDescription({
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
Drawer, DrawerClose,
|
||||
DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger
|
||||
}
|
||||
|
||||
|
@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"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:shadow-md",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -14,7 +14,7 @@ function Menubar({
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@ -257,20 +257,8 @@ function MenubarSubContent({
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
Menubar, MenubarCheckboxItem, MenubarContent,
|
||||
MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup,
|
||||
MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -59,7 +59,7 @@ function NavigationMenuItem({
|
||||
}
|
||||
|
||||
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({
|
||||
@ -156,13 +156,6 @@ function NavigationMenuIndicator({
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, NavigationMenuViewport
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -58,7 +58,7 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
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" &&
|
||||
"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" &&
|
||||
@ -128,12 +128,7 @@ function SheetDescription({
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
Sheet, SheetClose,
|
||||
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger
|
||||
}
|
||||
|
||||
|
@ -9,18 +9,18 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -296,7 +296,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
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",
|
||||
className
|
||||
)}
|
||||
@ -313,7 +313,7 @@ function SidebarInput({
|
||||
<Input
|
||||
data-slot="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}
|
||||
/>
|
||||
)
|
||||
@ -466,7 +466,7 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
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: {
|
||||
default: "h-8 text-sm",
|
||||
@ -683,29 +683,29 @@ function SidebarMenuSubButton({
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -53,7 +53,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -21,7 +21,7 @@ function Switch({
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
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>
|
||||
|
@ -1,4 +1,10 @@
|
||||
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 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,50 +1,94 @@
|
||||
import { METADATA_URL } from "@/constants"
|
||||
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
|
||||
*/
|
||||
|
||||
export async function getAllIcons(): Promise<IconFile> {
|
||||
const file = await fetch(METADATA_URL)
|
||||
return (await file.json()) as IconFile
|
||||
try {
|
||||
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.
|
||||
*/
|
||||
export const getIconNames = async (): Promise<string[]> => {
|
||||
const iconsData = await getAllIcons()
|
||||
return Object.keys(iconsData)
|
||||
try {
|
||||
const iconsData = await getAllIcons()
|
||||
return Object.keys(iconsData)
|
||||
} catch (error) {
|
||||
console.error("Error getting icon names:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts icon data to an array format for easier rendering
|
||||
*/
|
||||
export async function getIconsArray(): Promise<IconWithName[]> {
|
||||
const iconsData = await getAllIcons()
|
||||
try {
|
||||
const iconsData = await getAllIcons()
|
||||
|
||||
return Object.entries(iconsData)
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
data,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Object.entries(iconsData)
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
data,
|
||||
}))
|
||||
.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
|
||||
*/
|
||||
export async function getIconData(iconName: string): Promise<IconWithName | null> {
|
||||
const iconsData = await getAllIcons()
|
||||
const iconData = iconsData[iconName]
|
||||
try {
|
||||
const iconsData = await getAllIcons()
|
||||
const iconData = iconsData[iconName]
|
||||
|
||||
if (!iconData) {
|
||||
return null
|
||||
}
|
||||
if (!iconData) {
|
||||
throw new ApiError(`Icon '${iconName}' not found`, 404)
|
||||
}
|
||||
|
||||
return {
|
||||
name: iconName,
|
||||
data: iconData,
|
||||
return {
|
||||
name: iconName,
|
||||
data: iconData,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error("Error getting icon data:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,23 +96,75 @@ export async function getIconData(iconName: string): Promise<IconWithName | null
|
||||
* Fetches author data from GitHub API
|
||||
*/
|
||||
export async function getAuthorData(authorId: number) {
|
||||
const response = await fetch(`https://api.github.com/user/${authorId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
next: { revalidate: 86400 }, // Revalidate cache once a day
|
||||
})
|
||||
return response.json()
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/user/${authorId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
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()
|
||||
} 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() {
|
||||
const iconsData = await getAllIcons()
|
||||
try {
|
||||
const iconsData = await getAllIcons()
|
||||
|
||||
return {
|
||||
totalIcons: Object.keys(iconsData).length,
|
||||
return {
|
||||
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[]) {
|
||||
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)
|
||||
}
|
||||
|