1
1
mirror of https://github.com/neosubhamoy/neodlp.git synced 2026-03-22 16:05:50 +05:30

55 Commits

133 changed files with 13151 additions and 11413 deletions

View File

@@ -1,6 +1,4 @@
on: on: workflow_dispatch
release:
types: [published]
name: 🚀 Publish to AUR name: 🚀 Publish to AUR
jobs: jobs:
@@ -27,11 +25,11 @@ jobs:
# If manually triggered, fetch latest release # If manually triggered, fetch latest release
RELEASE_TAG=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name') RELEASE_TAG=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name')
fi fi
# Extract version number from tag # Extract version number from tag
VERSION=$(echo "$RELEASE_TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$/\1/') VERSION=$(echo "$RELEASE_TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$/\1/')
SUFFIX=$(echo "$RELEASE_TAG" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/\1/') SUFFIX=$(echo "$RELEASE_TAG" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/\1/')
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
@@ -39,15 +37,15 @@ jobs:
- name: 🔑 Setup SSH for AUR - name: 🔑 Setup SSH for AUR
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
# Write key with proper newline handling # Write key with proper newline handling
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | sed 's/\\n/\n/g' > ~/.ssh/id_rsa echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | sed 's/\\n/\n/g' > ~/.ssh/id_rsa
# Set proper permissions # Set proper permissions
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts
# Create SSH config file # Create SSH config file
cat > ~/.ssh/config << EOF cat > ~/.ssh/config << EOF
Host aur.archlinux.org Host aur.archlinux.org
@@ -66,7 +64,7 @@ jobs:
git config --global user.name "${{ secrets.AUR_USER }}" git config --global user.name "${{ secrets.AUR_USER }}"
git config --global user.email "${{ secrets.AUR_EMAIL }}" git config --global user.email "${{ secrets.AUR_EMAIL }}"
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
# Clone AUR repository # Clone AUR repository
GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=accept-new" \ GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=accept-new" \
git clone "ssh://aur@aur.archlinux.org/neodlp.git" aur-repo git clone "ssh://aur@aur.archlinux.org/neodlp.git" aur-repo
@@ -77,21 +75,21 @@ jobs:
# Update PKGBUILD version # Update PKGBUILD version
sed -i "s/pkgver=.*/pkgver=${VERSION}/" PKGBUILD sed -i "s/pkgver=.*/pkgver=${VERSION}/" PKGBUILD
# Create non-root user for makepkg (which refuses to run as root) # Create non-root user for makepkg (which refuses to run as root)
useradd -m builder useradd -m builder
chown -R builder:builder . chown -R builder:builder .
# Generate .SRCINFO using makepkg # Generate .SRCINFO using makepkg
su builder -c "makepkg --printsrcinfo" > .SRCINFO su builder -c "makepkg --printsrcinfo" > .SRCINFO
# Debug output # Debug output
echo "PKGBUILD:" echo "PKGBUILD:"
cat PKGBUILD cat PKGBUILD
echo ".SRCINFO:" echo ".SRCINFO:"
cat .SRCINFO cat .SRCINFO
# Check if there are any changes to commit # Check if there are any changes to commit
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
echo "Changes detected, committing and pushing..." echo "Changes detected, committing and pushing..."
@@ -108,4 +106,4 @@ jobs:
- name: 🔍 Verify update - name: 🔍 Verify update
run: | run: |
echo "Successfully updated AUR package to version ${{ steps.release_info.outputs.version }}${{ steps.release_info.outputs.suffix }}" echo "Successfully updated AUR package to version ${{ steps.release_info.outputs.version }}${{ steps.release_info.outputs.suffix }}"
echo "View the updated package at: https://aur.archlinux.org/packages/neodlp" echo "View the updated package at: https://aur.archlinux.org/packages/neodlp"

View File

@@ -1,16 +1,16 @@
### ✨ Changelog ### ✨ Changelog
- Added desktop notification settings (download completion, app updates) - Added delay/sleep configuration settings (delay is now also configurable on search)
- Added per-download sponsorblock config - Added support for YouTube PO Token generation (based on [bgutil-ytdlp-pot-provider-rs](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs))
- Added thumbnail embeding option for video files - Implemented custom app titlebar on windows and linux
- Fixed yt-dlp failing to update on Windows MSI install - Further improved and persisted app logs (stored in [platform specific log directory](https://v2.tauri.app/plugin/logging/#persisting-logs))
- Fixed yt-dlp not resolving deno on macOS - Fixed webview window creation is failing on wayland with nvidia gpu
- Other minor fixes and improvements - Other minor fixes and improvements
### 📝 Notes ### 📝 Notes
> [!CAUTION] > [!CAUTION]
> This is a breaking update if you are coming from older version than `v0.3.0`. Users are adviced to complete/cancel all paused downloads before updating to this version, otherwise paused downloads may not resume properly or re-start from the begining. > Users are always adviced to complete/cancel all paused downloads before updating to a newer version, otherwise paused downloads may not resume properly and re-start from the begining.
> [!WARNING] > [!WARNING]
> Linux users make sure `yt-dlp` and `deno` is not installed in your distro (otherwise you will get package installation conflict). Don't worry, You can still use yt-dlp cli as before (the only difference is that now it will be installed and auto-updated by neo-dlp, which You can also disable from neo-dlp Settings if you don't want to auto-update yt-dlp) > Linux users make sure `yt-dlp` and `deno` is not installed in your distro (otherwise you will get package installation conflict). Don't worry, You can still use yt-dlp cli as before (the only difference is that now it will be installed and auto-updated by neo-dlp, which You can also disable from neo-dlp Settings if you don't want to auto-update yt-dlp)
@@ -21,9 +21,9 @@
### 📦 Shipped Binaries ### 📦 Shipped Binaries
| yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno | | yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno | bgutil-pot-rs |
| :---- | :---- | :---- | :---- | :---- | | :---- | :---- | :---- | :---- | :---- | :---- |
| v2025.11.03.233024 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.5.6 | | v2026.02.17.233631 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.6.10 | v0.7.1-1.2.2 |
> ‼️ Linux builds (deb, rpm) does not ships with `ffmpeg` and `ffprobe` (though it will be auto installed as a dependency by your package manager, if you are on fedora make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package) > ‼️ Linux builds (deb, rpm) does not ships with `ffmpeg` and `ffprobe` (though it will be auto installed as a dependency by your package manager, if you are on fedora make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package)
@@ -31,15 +31,18 @@
### ⬇️ Download Section ### ⬇️ Download Section
| Arch\OS | Windows (msi) | Windows (exe) | Linux (deb) | Linux (rpm) | Linux (AppImage) | MacOS (dmg) | MacOS (app) | | Architecture | Windows (msi) ⬆️ | Windows (exe) ⬆️ | Linux (deb) | Linux (rpm) | Linux (AppImage) ⬆️ | MacOS (dmg) ⬆️ | MacOS (app) ⬆️ |
| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- |
| x86_64 | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_en-US_windows.msi) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup_windows.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64_linux.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.x86_64_linux.rpm) | 🚫 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64_linux.AppImage) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_darwin.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_darwin_x64.app.tar.gz) | | x86_64 | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_en-US.msi) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.x86_64.rpm) | 🚫 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.AppImage) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_x64.app.tar.gz) |
| ARM64 | N/A | 🪟 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup_windows.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_arm64_linux.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.aarch64_linux.rpm) | N/A | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64_darwin.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_darwin_aarch64.app.tar.gz) | | ARM64 | N/A | 🪟 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_arm64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.aarch64.rpm) | N/A | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_aarch64.app.tar.gz) |
> ⬆️ All packages supports in-built app-updater > ⬆️ icon indicates this packaging format supports in-built app-updater
> 🪟 Windows x86_64 binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact) > 🪟 Windows x86_64 binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact)
> 🚫 Linux AppImage builds are experimental and does not support neodlp's browser intergration features due to it's sandboxed nature. Also, don't run the AppImage with portable (.home, .config) folders, it will break things (it is highly recommended to use native [deb, rpm, AUR] builds if possible for the full experiance, otherwise AppImages are good for trying out NeoDLP without installing) > 🚫 Linux AppImage builds are experimental and does not support neodlp's browser intergration features due to it's sandboxed nature. Also, don't run the AppImage with portable (.home, .config) folders, it will break things (it is highly recommended to use native [deb, rpm, AUR] builds if possible for the full experiance, otherwise AppImages are good for trying out NeoDLP without installing)
> ⚠️ MacOS ARM64 binary downloads are experimental and may not open on Apple Silicon Macs if downloaded from browser (You will get 'Damaged File' error) it's because the binaries are not signed (signing MacOS binaries requires 99$/year Apple Developer Account subscription, which I can't afford RN!) and Apple Silicon Macs don't allow unsigned apps (downloaded from browser) to be installed on the system. If you want to use NeoDLP on your Apple Silicon Macs, you can simply use the command line [Curl-Bash Installer](https://neodlp.neosubhamoy.com/download) (Recommended) -OR- [compile it from source](https://github.com/neosubhamoy/neodlp?tab=readme-ov-file#%EF%B8%8F-contributing--building-from-source) in your Mac > ⚠️ MacOS ARM64 binary downloads are experimental and may not open on Apple Silicon Macs if downloaded from browser (You will get 'Damaged File' error) it's because the binaries are not signed (signing MacOS binaries requires 99$/year Apple Developer Account subscription, which I can't afford RN!) and Apple Silicon Macs don't allow unsigned apps (downloaded from browser) to be installed on the system. If you want to use NeoDLP on your Apple Silicon Macs, There are few ways you can bypass these restrictions:
> 1. Using our automated [Curl-Bash Installer](https://neodlp.neosubhamoy.com/download) (Recommended)
> 2. You can also manually remove the .dmg file/.app folder from macOS quarantine using these commands: `xattr -d com.apple.quarantine NeoDLP_x.x.x_aarch64.dmg` (for .dmg file) -OR- `xattr -r -d com.apple.quarantine /Applications/NeoDLP.app` (for .app folder)
> 3. Or you can [compile NeoDLP from source](https://github.com/neosubhamoy/neodlp?tab=readme-ov-file#%EF%B8%8F-building-from-source) in your Mac (Then you don't have to download the pre-compiled binaries at all, though it is a much longer process and is intended for advanced users only)

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Subhamoy Biswas Copyright (c) 2025 - Present Subhamoy Biswas
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

145
README.md
View File

@@ -1,40 +1,44 @@
![NeoDLP](./.github/images/banner.svg) ![NeoDLP](./.github/images/banner.svg)
# NeoDLP - (Neo Downloader Plus) # NeoDLP - Neo Downloader Plus
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration
[![status](https://img.shields.io/badge/status-active-brightgreen.svg?style=flat)](https://github.com/neosubhamoy/neodlp) [![github release](https://img.shields.io/github/v/release/neosubhamoy/neodlp?color=lime-green&style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases/latest)
[![github tag](https://img.shields.io/github/v/tag/neosubhamoy/neodlp?color=yellow)](https://github.com/neosubhamoy/neodlp) [![github downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total?style=for-the-badge)](https://github.com/neosubhamoy/neodlp/releases)
[![github downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total)](https://github.com/neosubhamoy/neodlp/releases) [![github stars](https://img.shields.io/github/stars/neosubhamoy/neodlp?color=yellow&style=for-the-badge)](https://github.com/neosubhamoy/neodlp/stargazers)
[![PRs](https://img.shields.io/badge/PRs-welcome-blue.svg?style=flat)](https://github.com/neosubhamoy/neodlp) [![github license](https://img.shields.io/github/license/neosubhamoy/neodlp?color=blue&style=for-the-badge)](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE)
> [!TIP] > [!TIP]
> **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!** > **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!**
[![Packaging status](https://repology.org/badge/vertical-allrepos/neodlp.svg)](https://repology.org/project/neodlp/versions) [![winget version](https://img.shields.io/winget/v/neosubhamoy.neodlp?color=lime-green&style=flat-square)](https://github.com/microsoft/winget-pkgs/tree/master/manifests/n/neosubhamoy/neodlp)
[![aur version](https://img.shields.io/aur/version/neodlp?color=lime-green&style=flat-square)](https://aur.archlinux.org/packages/neodlp)
## ✨ Highlighted Features ## ✨ Highlighted Features
- Download Video/Audio from popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)) - Download Video/Audio from thousands of popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md))
- Fully Configured YT-DLP Environment Out-of-the-Box (with JS Runtime, PO Token Server, Real-Time Logs etc.)
- Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.) - Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.)
- Supports both Video and Playlist download - Supports both Video and Playlist/Batch download
- Supports Combining Video, Audio streams of your choice - Supports Combining Video, Audio streams of your choice
- Supports Multi-Language Subtitle/Caption (CC) embeding - Supports Multi-Lingual Subtitle/Caption (CC) embeding
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.) - Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
- SponsorBlock support (mark/remove video segments) - SponsorBlock support (mark/remove video segments)
- Aria2 support (for blazing fast downloads)
- Network controls (proxy, rate limit etc.) - Network controls (proxy, rate limit etc.)
- Highly customizable and many more...😉 - Highly customizable and many more...😉
## 🧩 Browser Integration ## 🧩 Browser Integration
You can integrate NeoDLP with your favourite browser (any Chrome/Chromium/Firefox based browser) Just, install [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension) to get started! You can integrate NeoDLP with your favourite browser (any Chromium/Firefox based browser) Just, install [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension) to get started!
After installing the extension you can do the following directly from the browser: After installing the extension you can do the following directly from the browser:
- Quick Search (search current browser address with NeoDLP) (via pressing keyboard shortcut `ALT`+`SHIFT`+`Q`, You can also change this shortcut key combo from browser settings) - Quick Search (search current browser address with NeoDLP) (via pressing keyboard shortcut `ALT`+`SHIFT`+`Q`, You can also change this shortcut key combo from browser settings)
- Right Click Context Menu Action (Download with Neo Downloader Plus - Link, Selection, Media Source) - Right Click Context Menu Action (Search with Neo Downloader Plus - Link, Selection, Media Source)
## 👀 Sneak Peek ## 👀 Sneak Peek
@@ -52,10 +56,11 @@ After installing the extension you can do the following directly from the browse
## 🤝 External Dependencies ## 🤝 External Dependencies
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) - The core CLI tool used to download video/audio from the web (Hero of the show 😎) - [YT-DLP](https://github.com/yt-dlp/yt-dlp) [Unlicense] - The core CLI tool used to download video/audio from the web (Hero of the show 😎)
- [FFmpeg & FFprobe](https://www.ffmpeg.org) - Used for video/audio post-processing - [FFmpeg & FFprobe](https://www.ffmpeg.org) [LGPLv2.1+] - Used for video/audio post-processing
- [Aria2](https://aria2.github.io) - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds) - [Aria2](https://aria2.github.io) [GPLv2+] - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds)
- [Deno](https://deno.com) - Provides sandboxed javascript runtime environment for yt-dlp (Required for YT downloads, as per the new yt-dlp [announcement](https://github.com/yt-dlp/yt-dlp/issues/14404)) - [Deno](https://deno.com) [MIT] - Provides sandboxed javascript runtime environment for yt-dlp (Required for YT downloads, as per the new yt-dlp [announcement](https://github.com/yt-dlp/yt-dlp/issues/14404))
- [BgUtils POT Provider (Rust)](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) [GPLv3+] - Provides PO (Proof-of-Origin) Token for YT downloads
## System Pre-Requirements ## System Pre-Requirements
@@ -65,9 +70,9 @@ After installing the extension you can do the following directly from the browse
## ⬇️ Download and Installation ## ⬇️ Download and Installation
1. Download the latest [NeoDLP](https://github.com/neosubhamoy/neodlp/releases/latest) release based on your OS and CPU Architecture, then install it! -OR- Install it directly from an available distribution channel (listed below) 1. Download the latest NeoDLP release based on your OS and CPU Architecture, then install it! -OR- Install it directly from an available distribution channel (listed below)
| Arch\OS | Windows | Linux | MacOS | | Architecture | Windows | Linux | MacOS |
| :---- | :---- | :---- | :---- | | :---- | :---- | :---- | :---- |
| x86_64 | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | | x86_64 | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
| ARM64 | ✅ Emulation | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | | ARM64 | ✅ Emulation | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
@@ -77,10 +82,11 @@ After installing the extension you can do the following directly from the browse
| Platform (OS) | Distribution Channel | Installation Command / Instruction | | Platform (OS) | Distribution Channel | Installation Command / Instruction |
| :---- | :---- | :---- | | :---- | :---- | :---- |
| Windows x86_64 / ARM64 | WinGet | `winget install neodlp` | | Windows x86_64 / ARM64 | WinGet | `winget install neosubhamoy.neodlp` |
| MacOS x86_64 / ARM64 | Homebrew | `brew install neosubhamoy/tap/neodlp` |
| MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/macos_installer.sh \| bash` | | MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/macos_installer.sh \| bash` |
| Linux x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/linux_installer.sh \| bash` | | Linux x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/linux_installer.sh \| bash` |
| Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` | | Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` or `paru -S neodlp` |
## 🧪 Package Testing Status ## 🧪 Package Testing Status
@@ -89,6 +95,9 @@ Though NeoDLP is supported on most platforms but not all packages are tested on
> [!TIP] > [!TIP]
> If you have access to any of the untested systems listed below, you can test the packages there and send me the test results via creating an github issue! (that would be super helpful actualy 😊) > If you have access to any of the untested systems listed below, you can test the packages there and send me the test results via creating an github issue! (that would be super helpful actualy 😊)
<details>
<summary>Test Coverage</summary>
| Platform | Status | Platform | Status | | Platform | Status | Platform | Status |
| :---- | :---- | :---- | :---- | | :---- | :---- | :---- | :---- |
| Windows 10 (x64) | ✅ Tested | Windows 10 (ARM64) | ⚠️ Untested | | Windows 10 (x64) | ✅ Tested | Windows 10 (ARM64) | ⚠️ Untested |
@@ -102,6 +111,8 @@ Though NeoDLP is supported on most platforms but not all packages are tested on
| openSUSE 16 (x64) | ⚠️ Untested | openSUSE 16 (ARM64) | ⚠️ Untested | | openSUSE 16 (x64) | ⚠️ Untested | openSUSE 16 (ARM64) | ⚠️ Untested |
| RHEL 10 (x64) | ⚠️ Untested | RHEL 10 (ARM64) | ⚠️ Untested | | RHEL 10 (x64) | ⚠️ Untested | RHEL 10 (ARM64) | ⚠️ Untested |
</details>
## 💝 Support the Development ## 💝 Support the Development
NeoDLP is and will be always FREE to Use and Open-Sourced for Everyone. On the other hand the developent process of NeoDLP takes lots of time, effort and even sometimes money! So, if you appriciate my work and have the ability to donate, then please consider supporting the development by donating (even a very small donation matters and helps NeoDLP to be a better product!) Your support is the key to my motivation...🤗 NeoDLP is and will be always FREE to Use and Open-Sourced for Everyone. On the other hand the developent process of NeoDLP takes lots of time, effort and even sometimes money! So, if you appriciate my work and have the ability to donate, then please consider supporting the development by donating (even a very small donation matters and helps NeoDLP to be a better product!) Your support is the key to my motivation...🤗
@@ -121,67 +132,81 @@ NeoDLP is and will be always FREE to Use and Open-Sourced for Everyone. On the o
- [x] Integrate with browsers - [x] Integrate with browsers
- [x] Add aria2c support - [x] Add aria2c support
- [x] Add custom command support - [x] Add custom command support
- [ ] Add more advanced settings and achive stability **(ongoing)** - [x] Add full-playlist/batch download support
- [ ] Add media converter - [ ] Improve browser integration **(ongoing)**
- [ ] Add multiple downloader engines - [ ] Implement NeoDLP API
- [ ] Add advanced web extractor - [ ] Build web interface
- [ ] Implement plugin system
- [ ] Add more cool stuffs 😉 - [ ] Add more cool stuffs 😉
## ⚡ Technologies Used ## ⚡ Technologies Used
![Tauri](https://img.shields.io/badge/tauri-%2324C8DB.svg?style=for-the-badge&logo=tauri&logoColor=%23FFFFFF) [![Tauri](https://img.shields.io/badge/tauri-%2324C8DB.svg?style=for-the-badge&logo=tauri&logoColor=%23FFFFFF)](https://tauri.app)
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) [![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)](https://rust-lang.org)
![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) [![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)](https://react.dev)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) [![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
![ShadCnUi](https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white) [![ShadCnUi](https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white)](https://ui.shadcn.com)
## 🛠️ Contributing / Building from Source ## 🛠️ Building from Source
Want to be part of this? Feel free to contribute...!! Pull Requests are always welcome...!! (^_^) Follow these simple steps to start building: Want to build/compile NeoDLP from the source code? Follow these simple steps to create a production build:
* Make sure to install [Rust](https://www.rust-lang.org/tools/install), [Node.js](https://nodejs.org/en), and [Git](https://git-scm.com/downloads) before proceeding. * Make sure to install [Rust](https://www.rust-lang.org/tools/install), [Node.js](https://nodejs.org/en), and [Git](https://git-scm.com/downloads) before proceeding.
* Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform * Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform
1. Fork this repo in your github account. 1. Clone this repo in your local machine: `git clone https://github.com/neosubhamoy/neodlp.git`
2. Git clone the forked repo in your local machine. 2. Go inside the cloned project directory: `cd neodlp`
3. Create a git branch (related to the feature you are working on) (Optional - Recommended) 3. Install Node.js dependencies: `npm install`
4. Install Node.js dependencies: `npm install` 4. Download required external binaries (for your platform): `npm run download`
5. Download binaries (for current platform): `npm run download` 5. Run build process (run the command based on your platform and architecture)
6. Run development / build process ```shell
> [!WARNING] # command for windows users
> Make sure to run the `build` command once before running the `dev` command for the first time to avoid compile time errors npm run tauri build # for both x64/ARM64 devices
```code
# for windows users
npm run tauri dev # for development
npm run tauri build # for production build
# for linux users (based on cpu architecture) # commands for linux users
npm run tauri dev -- --config "./src-tauri/tauri.linux-aarch64.conf.json" # for ARM64 devices, development npm run tauri:build:linux-x64 # for x64 devices
npm run tauri build -- --config "./src-tauri/tauri.linux-aarch64.conf.json" # for ARM64 devices, production build npm run tauri:build:linux-arm64 # for ARM64 devices
npm run tauri dev -- --config "./src-tauri/tauri.linux-x86_64.conf.json" # for x64 devices, development # commands for macOS users
npm run tauri build -- --config "./src-tauri/tauri.linux-x86_64.conf.json" # for x64 devices, production build npm run tauri:build:macos-arm64 # for apple silicon macs
npm run tauri:build:macos-x64 # for intel x86 macs
# for macOS users (based on cpu architecture)
npm run tauri dev -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, development
npm run tauri build -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, production build
npm run tauri dev -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, development
npm run tauri build -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, production build
``` ```
6. Do the changes, Send a Pull Request with proper Description (NOTE: Pull Requests Without Proper Description will be Rejected) 6. Give it the time to compile (~5-10min) (if you get an error, something like this at the end: `Error A public key has been found, but no private key. Make sure to set 'TAURI_SIGNING_PRIVATE_KEY' environment variable.` simply ignore it! Your build is successfull!). You can find the compiled packages under: `src-tauri/target/release/bundle` directory.
## Bug Report ## 🐞 Bug Report and Discussions
Noticed any Bug? or Want to give me some suggetion? Always feel free to open a [GitHub Issue](https://github.com/neosubhamoy/neodlp/issues). I would love to hear from you...!! Noticed any Bug? or Want to give us some suggetions? Always feel free to let us know! We would love to hear from you...!! You can reach us out via the following methods:
- GitHub Issues (Recommended): [Report a Bug](https://github.com/neosubhamoy/neodlp/issues/new?template=bug_report.md) -OR- [Request a Feature](https://github.com/neosubhamoy/neodlp/issues/new?template=feature_request.md)
- Mailing List: If you prefer the good old mailing list way, You can just simply write us on [support@neodlp.neosubhamoy.com](mailto:support@neodlp.neosubhamoy.com) (Kindly follow the Bug Report/Feature Request Template on that case)
- Reddit Community: If you have any other general pourpose query/discussion related to NeoDLP, post it on our subreddit community [r/NeoDLP](https://www.reddit.com/r/NeoDLP)
## 📦 Sources
- [Official Website](https://neodlp.neosubhamoy.com)
- Official Repositories
- [GitHub (Primary)](https://github.com/neosubhamoy/neodlp)
- [Gitea (Mirror)](https://gitea.neosubhamoy.com/neosubhamoy/neodlp)
- [SourceForge (Releases Only)](https://sourceforge.net/projects/neodlp)
- Official Distribution Channels
- [WinGet (for Windows)](https://github.com/microsoft/winget-pkgs/tree/master/manifests/n/neosubhamoy/neodlp)
- [AUR (for Arch Linux)](https://aur.archlinux.org/packages/neodlp)
- Related Projects
- [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension)
- [NeoDLP Website](https://github.com/neosubhamoy/neodlp-website)
## 💫 Credits ## 💫 Credits
- NeoDLP is made possible by the joint efforts of [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://www.ffmpeg.org). Lots of NeoDLP features are actually powered by these tools under the hood! So huge thanks to all the developers/contributers for making these great tools! 🙏
- NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02) - NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02)
- Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds) - Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds)
- NeoDLP's 'POT Server' is based on [@jim60105's Rust Implementation](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) of [Brainicism/bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
## 📝 License ## ⚖️ License and Usage
NeoDLP is Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any attribution and extra permissions. NeoDLP is a Fully Open-Source Software Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any extra permission (Just include the LICENSE file :)
> [!WARNING]
> NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.
**** ****
An Open Sourced Project - Developed with ❤️ by **Subhamoy** An Open Sourced Project - Developed with ❤️ by **Subhamoy**

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=NeoDLP
Comment=Modern video/audio downloader based on yt-dlp with browser integration.
Icon=com.neosubhamoy.neodlp
Exec=neodlp
Terminal=false
Categories=Utility;
Keywords=neodlp;downloader;yt-dlp-gui;

View File

@@ -0,0 +1,31 @@
<?xml version='1.0' encoding='UTF-8'?>
<component type="desktop-application">
<id>com.neosubhamoy.neodlp</id>
<name>NeoDLP</name>
<summary>Modern video/audio downloader based on yt-dlp with browser integration</summary>
<developer_name>Subhamoy Biswas</developer_name>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://neodlp.neosubhamoy.com</url>
<url type="bugtracker">https://github.com/neosubhamoy/neodlp/issues</url>
<description>
<p>
NeoDLP is a cross-platform desktop application designed for downloading videos and audio from various online sources based on yt-dlp.
It offers modern user interface, lots of customization options and seamless browser integration.
</p>
</description>
<launchable type="desktop-id">com.neosubhamoy.neodlp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/downloader.png</image>
<caption>NeoDLP Downloader</caption>
</screenshot>
</screenshots>
<content_rating type="oars-1.1" />
<releases>
<release version="0.4.2" date="2026-02-23">
<url type="details">https://github.com/neosubhamoy/neodlp/releases/tag/v0.4.2</url>
</release>
</releases>
</component>

2912
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +1,72 @@
{ {
"name": "neodlp", "name": "neodlp",
"private": true, "private": true,
"version": "0.3.3", "version": "0.4.1",
"description": "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev:linux-x64": "npm run tauri dev -- --config ./src-tauri/tauri.linux-x86_64.conf.json",
"tauri:build:linux-x64": "npm run tauri build -- --config ./src-tauri/tauri.linux-x86_64.conf.json",
"tauri:dev:linux-arm64": "npm run tauri dev -- --config ./src-tauri/tauri.linux-aarch64.conf.json",
"tauri:build:linux-arm64": "npm run tauri build -- --config ./src-tauri/tauri.linux-aarch64.conf.json",
"tauri:dev:macos-x64": "npm run tauri dev -- --config ./src-tauri/tauri.macos-x86_64.conf.json",
"tauri:build:macos-x64": "npm run tauri build -- --config ./src-tauri/tauri.macos-x86_64.conf.json",
"tauri:dev:macos-arm64": "npm run tauri dev -- --config ./src-tauri/tauri.macos-aarch64.conf.json",
"tauri:build:macos-arm64": "npm run tauri build -- --config ./src-tauri/tauri.macos-aarch64.conf.json",
"download": "node ./scripts/download-bins.js" "download": "node ./scripts/download-bins.js"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@tanstack/devtools-vite": "^0.5.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@tanstack/react-devtools": "^0.9.6",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@tanstack/react-pacer": "^0.20.0",
"@radix-ui/react-avatar": "^1.1.11", "@tanstack/react-pacer-devtools": "^0.5.2",
"@radix-ui/react-checkbox": "^1.3.3", "@tanstack/react-query": "^5.90.21",
"@radix-ui/react-collapsible": "^1.1.12", "@tanstack/react-query-devtools": "^5.91.3",
"@radix-ui/react-context-menu": "^2.2.16", "@tauri-apps/api": "^2.10.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.3", "@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-sql": "^2.3.1", "@tauri-apps/plugin-sql": "^2.3.2",
"@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-updater": "^2.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "lucide-react": "^0.574.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.552.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "radix-ui": "^1.4.3",
"react-day-picker": "^9.11.1", "react": "^19.2.4",
"react-dom": "^19.2.0", "react-dom": "^19.2.4",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^4.6.4",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.13.0",
"recharts": "^3.3.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.1",
"ulid": "^3.0.1", "ulid": "^3.0.2",
"vaul": "^1.1.2", "zod": "^4.3.6",
"zod": "^4.1.12", "zustand": "^5.0.11"
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.2.0",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.2.0",
"@tauri-apps/cli": "^2.9.3", "@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.0", "@types/node": "^25.2.3",
"@types/react": "^19.2.2", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.0" "vite": "^7.3.1"
} }
} }

View File

@@ -20,6 +20,7 @@ const versions = {
'ffmpeg-ffprobe': 'latest', 'ffmpeg-ffprobe': 'latest',
'deno': 'latest', 'deno': 'latest',
'aria2c': '1.37.0', 'aria2c': '1.37.0',
'neodlp-pot': 'latest'
}; };
const binaries = { const binaries = {
@@ -145,10 +146,54 @@ const binaries = {
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl') path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl')
] ]
}, },
// {
// name: 'ffmpeg-universal-apple-darwin',
// platform: 'darwin',
// url: `https://evermeet.cx/ffmpeg/get/zip`,
// src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
// dest: null,
// archive: {
// type: 'zip',
// binSrc: [
// path.join(downloadDir, 'ffmpeg'),
// path.join(downloadDir, 'ffmpeg')
// ],
// binDest: [
// path.join(binDir, 'ffmpeg-x86_64-apple-darwin'),
// path.join(binDir, 'ffmpeg-aarch64-apple-darwin')
// ]
// },
// cleanup: [
// path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
// path.join(downloadDir, 'ffmpeg')
// ]
// },
// {
// name: 'ffprobe-universal-apple-darwin',
// platform: 'darwin',
// url: `https://evermeet.cx/ffmpeg/get/ffprobe/zip`,
// src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
// dest: null,
// archive: {
// type: 'zip',
// binSrc: [
// path.join(downloadDir, 'ffprobe'),
// path.join(downloadDir, 'ffprobe')
// ],
// binDest: [
// path.join(binDir, 'ffprobe-x86_64-apple-darwin'),
// path.join(binDir, 'ffprobe-aarch64-apple-darwin')
// ]
// },
// cleanup: [
// path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
// path.join(downloadDir, 'ffprobe')
// ]
// },
{ {
name: 'ffmpeg-universal-apple-darwin', name: 'ffmpeg-universal-apple-darwin',
platform: 'darwin', platform: 'darwin',
url: `https://evermeet.cx/ffmpeg/get/zip`, url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-universal-apple-darwin.zip`,
src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'), src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
dest: null, dest: null,
archive: { archive: {
@@ -170,7 +215,7 @@ const binaries = {
{ {
name: 'ffprobe-universal-apple-darwin', name: 'ffprobe-universal-apple-darwin',
platform: 'darwin', platform: 'darwin',
url: `https://evermeet.cx/ffmpeg/get/ffprobe/zip`, url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffprobe-universal-apple-darwin.zip`,
src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'), src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
dest: null, dest: null,
archive: { archive: {
@@ -353,6 +398,73 @@ const binaries = {
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`) path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`)
] ]
} }
],
'neodlp-pot': [
{
name: 'neodlp-pot-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/'+versions['neodlp-pot'] : ''}/bgutil-pot-windows-x86_64.exe`,
src: path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-pc-windows-msvc.exe')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe')
]
},
{
name: 'neodlp-pot-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-x86_64`,
src: path.join(downloadDir, 'bgutil-pot-linux-x86_64'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-linux-x86_64')
]
},
{
name: 'neodlp-pot-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-aarch64`,
src: path.join(downloadDir, 'bgutil-pot-linux-aarch64'),
dest: [
path.join(binDir, 'neodlp-pot-aarch64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-linux-aarch64')
]
},
{
name: 'neodlp-pot-x86_64-apple-darwin',
platform: 'darwin',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-x86_64`,
src: path.join(downloadDir, 'bgutil-pot-macos-x86_64'),
dest: [
path.join(binDir, 'neodlp-pot-x86_64-apple-darwin')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-macos-x86_64')
]
},
{
name: 'neodlp-pot-aarch64-apple-darwin',
platform: 'darwin',
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-aarch64`,
src: path.join(downloadDir, 'bgutil-pot-macos-aarch64'),
dest: [
path.join(binDir, 'neodlp-pot-aarch64-apple-darwin')
],
archive: null,
cleanup: [
path.join(downloadDir, 'bgutil-pot-macos-aarch64')
]
}
] ]
} }

2004
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "neodlp" name = "neodlp"
version = "0.3.3" version = "0.4.1"
description = "NeoDLP" description = "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration"
authors = ["neosubhamoy <hey@neosubhamoy.com>"] authors = ["neosubhamoy <hey@neosubhamoy.com>"]
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -22,13 +22,14 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.13", features = ["json"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "*" tokio-tungstenite = "*"
sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] } sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] }
base64 = "0.22" base64 = "0.22"
directories = "6.0" directories = "6.0"
futures-util = "0.3" futures-util = "0.3"
log = "0.4"
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -39,6 +40,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-clipboard-manager = "2" tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-log = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"

View File

@@ -10,6 +10,10 @@
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-start-dragging",
"opener:default", "opener:default",
"shell:default", "shell:default",
"fs:default", "fs:default",
@@ -25,6 +29,7 @@
"clipboard-manager:allow-read-text", "clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"notification:default", "notification:default",
"log:default",
{ {
"identifier": "opener:allow-open-path", "identifier": "opener:allow-open-path",
"allow": [ "allow": [

View File

@@ -35,6 +35,11 @@
"args": true, "args": true,
"sidecar": true "sidecar": true
}, },
{
"name": "binaries/neodlp-pot",
"args": true,
"sidecar": true
},
{ {
"name": "ffmpeg", "name": "ffmpeg",
"cmd": "ffmpeg", "cmd": "ffmpeg",
@@ -45,6 +50,11 @@
"cmd": "aria2c", "cmd": "aria2c",
"args": true "args": true
}, },
{
"name": "deno",
"cmd": "deno",
"args": true
},
{ {
"name": "pkexec", "name": "pkexec",
"cmd": "pkexec", "cmd": "pkexec",
@@ -54,6 +64,11 @@
"name": "powershell", "name": "powershell",
"cmd": "powershell", "cmd": "powershell",
"args": true "args": true
},
{
"name": "sh",
"cmd": "sh",
"args": true
} }
] ]
}, },
@@ -85,6 +100,11 @@
"args": true, "args": true,
"sidecar": true "sidecar": true
}, },
{
"name": "binaries/neodlp-pot",
"args": true,
"sidecar": true
},
{ {
"name": "ffmpeg", "name": "ffmpeg",
"cmd": "ffmpeg", "cmd": "ffmpeg",
@@ -94,6 +114,11 @@
"name": "aria2c", "name": "aria2c",
"cmd": "aria2c", "cmd": "aria2c",
"args": true "args": true
},
{
"name": "deno",
"cmd": "deno",
"args": true
} }
] ]
} }

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
__version__ = '1.2.2'
import abc
import json
from yt_dlp.extractor.youtube.pot.provider import (
ExternalRequestFeature,
PoTokenContext,
PoTokenProvider,
PoTokenProviderRejectedRequest,
)
from yt_dlp.extractor.youtube.pot.utils import WEBPO_CLIENTS
from yt_dlp.utils import js_to_json
from yt_dlp.utils.traversal import traverse_obj
class BgUtilPTPBase(PoTokenProvider, abc.ABC):
PROVIDER_VERSION = __version__
BUG_REPORT_LOCATION = (
'https://github.com/jim60105/bgutil-ytdlp-pot-provider/issues'
)
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
ExternalRequestFeature.PROXY_SCHEME_HTTP,
ExternalRequestFeature.PROXY_SCHEME_HTTPS,
ExternalRequestFeature.PROXY_SCHEME_SOCKS4,
ExternalRequestFeature.PROXY_SCHEME_SOCKS4A,
ExternalRequestFeature.PROXY_SCHEME_SOCKS5,
ExternalRequestFeature.PROXY_SCHEME_SOCKS5H,
ExternalRequestFeature.SOURCE_ADDRESS,
ExternalRequestFeature.DISABLE_TLS_VERIFICATION,
)
_SUPPORTED_CLIENTS = WEBPO_CLIENTS
_SUPPORTED_CONTEXTS = (
PoTokenContext.GVS,
PoTokenContext.PLAYER,
PoTokenContext.SUBS,
)
_GETPOT_TIMEOUT = 20.0
_GET_SERVER_VSN_TIMEOUT = 5.0
_MIN_NODE_VSN = (18, 0, 0)
def _info_and_raise(self, msg, raise_from=None):
self.logger.info(msg)
raise PoTokenProviderRejectedRequest(msg) from raise_from
def _warn_and_raise(self, msg, once=True, raise_from=None):
self.logger.warning(msg, once=once)
raise PoTokenProviderRejectedRequest(msg) from raise_from
def _get_attestation(self, webpage: str | None):
if not webpage:
return None
raw_challenge_data = self.ie._search_regex(
r'''(?sx)window\.ytAtR\s*=\s*(?P<raw_cd>(?P<q>['"])
(?:
\\.|
(?!(?P=q)).
)*
(?P=q))\s*;''',
webpage,
'raw challenge data',
default=None,
group='raw_cd',
)
att_txt = traverse_obj(
raw_challenge_data,
({js_to_json}, {json.loads}, {json.loads}, 'bgChallenge')
)
if not att_txt:
self.logger.warning(
'Failed to extract initial attestation from the webpage'
)
return None
return att_txt
__all__ = ['__version__']

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import functools
import json
import os.path
import shutil
import subprocess
from yt_dlp.extractor.youtube.pot.provider import (
PoTokenProviderError,
PoTokenRequest,
PoTokenResponse,
register_preference,
register_provider,
)
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
from yt_dlp.utils import Popen
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
@register_provider
class BgUtilCliPTP(BgUtilPTPBase):
PROVIDER_NAME = 'bgutil:cli'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._check_cli = functools.cache(self._check_cli_impl)
@functools.cached_property
def _cli_path(self):
cli_path = self._configuration_arg(
'cli_path', casesense=True, default=[None])[0]
if cli_path:
return os.path.expandvars(cli_path)
# check deprecated arg
deprecated_cli_path = self.ie._configuration_arg(
ie_key='youtube', key='getpot_bgutil_script', default=[None])[0]
if deprecated_cli_path:
self._warn_and_raise(
"'youtube:getpot_bgutil_script' extractor arg is deprecated, "
"use 'youtubepot-bgutilcli:cli_path' instead")
# default if no arg was passed
# First, try to find the executable in PATH
if self._get_executable_path('bgutil-pot'):
self.logger.debug('Found bgutil-pot in PATH')
return 'bgutil-pot'
# Then check common file locations
file_paths = [
os.path.join(
os.getcwd(), 'target', 'debug', 'bgutil-pot'
),
os.path.join(
os.getcwd(), 'target', 'release', 'bgutil-pot'
),
os.path.expanduser(
'~/bgutil-ytdlp-pot-provider/target/debug/bgutil-pot'
),
os.path.expanduser(
'~/bgutil-ytdlp-pot-provider/target/release/'
'bgutil-pot'
),
]
for path in file_paths:
if self._get_executable_path(path):
self.logger.debug(f'Found bgutil-pot at: {path}')
return path
# Fallback to PATH name if no file found
default_path = 'bgutil-pot'
self.logger.debug(
f'No CLI path found, defaulting to {default_path}')
return default_path
def is_available(self):
return self._check_cli(self._cli_path)
def _get_executable_path(self, cli_path):
"""Get the actual executable path, checking PATH or file existence."""
# For relative names (like 'bgutil-pot-generate'), search in PATH
if os.path.sep not in cli_path:
executable_path = shutil.which(cli_path)
if executable_path:
return executable_path
# For absolute/relative paths, check file existence directly
if os.path.isfile(cli_path):
return cli_path
return None
def _check_cli_impl(self, cli_path):
executable_path = self._get_executable_path(cli_path)
if not executable_path:
self.logger.debug(
f"Executable path doesn't exist: {cli_path}")
return False
stdout, stderr, returncode = Popen.run(
[executable_path, '--version'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=self._GET_SERVER_VSN_TIMEOUT
)
if returncode:
self.logger.warning(
f'Failed to check executable version. '
f'Executable returned {returncode} exit status. '
f'stdout: {stdout}; stderr: {stderr}',
once=True)
return False
else:
self.logger.debug(f'bgutil-pot version: {stdout.strip()}')
return True
def _real_request_pot(
self,
request: PoTokenRequest,
) -> PoTokenResponse:
# used for CI check
self.logger.trace(
f'Generating POT via Rust executable: {self._cli_path}')
executable_path = self._get_executable_path(self._cli_path)
if not executable_path:
raise PoTokenProviderError(
f'Executable not found: {self._cli_path}')
command_args = [executable_path]
if proxy := request.request_proxy:
command_args.extend(['-p', proxy])
command_args.extend(['-c', get_webpo_content_binding(request)[0]])
if request.bypass_cache:
command_args.append('--bypass-cache')
if request.request_source_address:
command_args.extend(
['--source-address', request.request_source_address])
if request.request_verify_tls is False:
command_args.append('--disable-tls-verification')
self.logger.info(
f'Generating a {request.context.value} PO Token for '
f'{request.internal_client_name} client via bgutil '
f'Rust executable',
)
self.logger.debug(
f'Executing command to get POT via Rust executable: '
f'{" ".join(command_args)}'
)
try:
stdout, stderr, returncode = Popen.run(
command_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=self._GETPOT_TIMEOUT
)
except subprocess.TimeoutExpired as e:
raise PoTokenProviderError(
f'_get_pot_via_cli failed: Timeout expired when trying '
f'to run executable (caused by {e!r})'
)
except Exception as e:
raise PoTokenProviderError(
f'_get_pot_via_cli failed: Unable to run executable '
f'(caused by {e!r})'
) from e
msg = ''
if stdout_extra := stdout.strip().splitlines()[:-1]:
msg = f'stdout:\n{stdout_extra}\n'
if stderr_stripped := stderr.strip(): # Empty strings are falsy
msg += f'stderr:\n{stderr_stripped}\n'
msg = msg.strip()
if msg:
self.logger.trace(msg)
if returncode:
raise PoTokenProviderError(
f'_get_pot_via_cli failed with returncode {returncode}')
try:
json_resp = stdout.splitlines()[-1]
self.logger.trace(f'JSON response:\n{json_resp}')
# The JSON response is always the last line
cli_data_resp = json.loads(json_resp)
except json.JSONDecodeError as e:
raise PoTokenProviderError(
f'Error parsing JSON response from _get_pot_via_cli '
f'(caused by {e!r})'
) from e
if 'poToken' not in cli_data_resp:
raise PoTokenProviderError(
'The executable did not respond with a po_token')
return PoTokenResponse(po_token=cli_data_resp['poToken'])
@register_preference(BgUtilCliPTP)
def bgutil_cli_getpot_preference(provider, request):
return 1
__all__ = [BgUtilCliPTP.__name__,
bgutil_cli_getpot_preference.__name__]

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import functools
import json
import time
from yt_dlp.extractor.youtube.pot.provider import (
PoTokenProviderError,
PoTokenProviderRejectedRequest,
PoTokenRequest,
PoTokenResponse,
register_preference,
register_provider,
)
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
from yt_dlp.networking.common import Request
from yt_dlp.networking.exceptions import HTTPError, TransportError
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
@register_provider
class BgUtilHTTPPTP(BgUtilPTPBase):
PROVIDER_NAME = 'bgutil:http'
DEFAULT_BASE_URL = 'http://127.0.0.1:4416'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_server_check = 0
self._server_available = True
@functools.cached_property
def _base_url(self):
base_url = self._configuration_arg('base_url', default=[None])[0]
if base_url:
return base_url
# check deprecated arg
deprecated_base_url = self.ie._configuration_arg(
ie_key='youtube', key='getpot_bgutil_baseurl', default=[None])[0]
if deprecated_base_url:
self._warn_and_raise(
"'youtube:getpot_bgutil_baseurl' extractor arg is deprecated, "
"use 'youtubepot-bgutilhttp:base_url' instead"
)
# default if no arg was passed
self.logger.debug(
f'No base_url provided, defaulting to {self.DEFAULT_BASE_URL}')
return self.DEFAULT_BASE_URL
def _check_server_availability(self, ctx: PoTokenRequest):
if self._last_server_check + 60 > time.time():
return self._server_available
self._server_available = False
try:
self.logger.trace(
f'Checking server availability at {self._base_url}/ping')
response = json.load(self._request_webpage(Request(
f'{self._base_url}/ping',
extensions={'timeout': self._GET_SERVER_VSN_TIMEOUT},
proxies={'all': None}
),
note=False))
except TransportError as e:
# the server may be down
script_path_provided = self.ie._configuration_arg(
ie_key='youtubepot-bgutilscript',
key='script_path',
default=[None]
)[0] is not None
warning_base = (
f'Error reaching GET {self._base_url}/ping '
f'(caused by {e.__class__.__name__}). '
)
if script_path_provided: # server down is expected, log info
self._info_and_raise(
warning_base +
'This is expected if you are using the script method.'
)
else:
self._warn_and_raise(
warning_base +
f'Please make sure that the server is reachable at '
f'{self._base_url}.'
)
return
except HTTPError as e:
# may be an old server, don't raise
self.logger.warning(
f'HTTP Error reaching GET /ping (caused by {e!r})', once=True)
return
except json.JSONDecodeError as e:
# invalid server
self._warn_and_raise(
f'Error parsing ping response JSON (caused by {e!r})')
return
except Exception as e:
self._warn_and_raise(
f'Unknown error reaching GET /ping (caused by {e!r})',
raise_from=e
)
return
else:
version = response.get("version", "unknown")
self.logger.debug(f'HTTP server version: {version}')
self._server_available = True
return True
finally:
self._last_server_check = time.time()
def is_available(self):
return (self._server_available or
self._last_server_check + 60 < int(time.time()))
def _real_request_pot(
self,
request: PoTokenRequest,
) -> PoTokenResponse:
if not self._check_server_availability(request):
raise PoTokenProviderRejectedRequest(
f'{self.PROVIDER_NAME} server is not available')
# used for CI check
self.logger.trace('Generating POT via HTTP server')
disable_innertube = bool(
self._configuration_arg('disable_innertube', default=[None])[0]
)
challenge = self._get_attestation(
None if disable_innertube else request.video_webpage
)
# The challenge is falsy when the webpage and the challenge are
# unavailable. In this case, we need to disable /att/get since
# it's broken for web_music
if not challenge and request.internal_client_name == 'web_music':
if not disable_innertube: # if not already set, warn the user
self.logger.warning(
'BotGuard challenges could not be obtained from the '
'webpage, overriding disable_innertube=True because '
'InnerTube challenges are currently broken for the '
'web_music client. Pass disable_innertube=1 to suppress '
'this warning.'
)
disable_innertube = True
try:
response = self._request_webpage(
request=Request(
f'{self._base_url}/get_pot', data=json.dumps({
'bypass_cache': request.bypass_cache,
'challenge': challenge,
'content_binding': get_webpo_content_binding(
request
)[0],
'disable_innertube': disable_innertube,
'disable_tls_verification': (
not request.request_verify_tls
),
'proxy': request.request_proxy,
'innertube_context': request.innertube_context,
'source_address': request.request_source_address,
}).encode(), headers={'Content-Type': 'application/json'},
extensions={'timeout': self._GETPOT_TIMEOUT},
proxies={'all': None}
),
note=f'Generating a {request.context.value} PO Token for '
f'{request.internal_client_name} client via bgutil '
f'HTTP server',
)
except Exception as e:
raise PoTokenProviderError(
f'Error reaching POST /get_pot (caused by {e!r})') from e
try:
response_json = json.load(response)
except Exception as e:
response_data = response.read().decode()
raise PoTokenProviderError(
f'Error parsing response JSON (caused by {e!r}). '
f'response = {response_data}'
) from e
if error_msg := response_json.get('error'):
raise PoTokenProviderError(error_msg)
if 'poToken' not in response_json:
raise PoTokenProviderError(
f'Server did not respond with a poToken. '
f'Received response: {response}'
)
po_token = response_json['poToken']
self.logger.trace(f'Generated POT: {po_token}')
return PoTokenResponse(po_token=po_token)
@register_preference(BgUtilHTTPPTP)
def bgutil_HTTP_getpot_preference(provider, request):
return 130
__all__ = [BgUtilHTTPPTP.__name__,
bgutil_HTTP_getpot_preference.__name__]

View File

@@ -21,6 +21,7 @@ use std::{
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
path::BaseDirectory,
Emitter, Manager, State, Emitter, Manager, State,
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
@@ -184,6 +185,16 @@ fn get_current_app_path() -> Result<String, String> {
.into_owned()) .into_owned())
} }
#[tauri::command]
fn is_flatpak() -> bool {
std::env::var("FLATPAK").is_ok()
}
#[tauri::command]
fn get_appimage_path() -> Option<String> {
std::env::var("APPDIR").ok()
}
#[tauri::command] #[tauri::command]
async fn update_config( async fn update_config(
new_config: Config, new_config: Config,
@@ -477,6 +488,11 @@ pub async fn run() {
let start_hidden = args.contains(&"--hidden".to_string()); let start_hidden = args.contains(&"--hidden".to_string());
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.max_file_size(5_242_880) /* in bytes = 5MB */
.build(),
)
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus the main window when attempting to launch another instance // Focus the main window when attempting to launch another instance
@@ -485,10 +501,9 @@ pub async fn run() {
let _ = window.set_focus(); let _ = window.set_focus();
} }
})) }))
.plugin( .plugin(tauri_plugin_sql::Builder::default()
tauri_plugin_sql::Builder::default() .add_migrations("sqlite:database.db", migrations)
.add_migrations("sqlite:database.db", migrations) .build(),
.build(),
) )
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
@@ -570,6 +585,20 @@ pub async fn run() {
.build(app) .build(app)
.map_err(|e| format!("Failed to create tray: {}", e))?; .map_err(|e| format!("Failed to create tray: {}", e))?;
// Fix tray icon in sandboxed environments (e.g., Flatpak)
// libappindicator uses the full path of the icon in dbus messages,
// so the path needs to be accessible from both the host and the sandbox.
// The default /tmp path doesn't work across sandbox boundaries.
if let Ok(local_data_path) = app
.path()
.resolve("tray-icon", BaseDirectory::AppLocalData)
{
let _ = fs::create_dir_all(&local_data_path);
let _ = tray.set_temp_dir_path(Some(local_data_path));
// Re-set the icon so it gets written to the new temp dir path
let _ = tray.set_icon(Some(app.default_window_icon().unwrap().clone()));
}
app.manage(tray); app.manage(tray);
let window = app.get_webview_window("main").unwrap(); let window = app.get_webview_window("main").unwrap();
@@ -600,6 +629,8 @@ pub async fn run() {
get_config_file_path, get_config_file_path,
restart_websocket_server, restart_websocket_server,
get_current_app_path, get_current_app_path,
is_flatpak,
get_appimage_path
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -3,5 +3,12 @@
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
#[cfg(target_os = "linux")]
{
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
neodlp_lib::run().await neodlp_lib::run().await
} }

View File

@@ -148,5 +148,94 @@ pub fn get_migrations() -> Vec<Migration> {
END; END;
", ",
kind: MigrationKind::Up, kind: MigrationKind::Up,
},
Migration {
version: 3,
description: "add_more_columns_and_indices_to_downloads",
sql: "
-- Create temporary table with all new columns
CREATE TABLE downloads_temp (
id INTEGER PRIMARY KEY NOT NULL,
download_id TEXT UNIQUE NOT NULL,
download_status TEXT NOT NULL,
video_id TEXT NOT NULL,
format_id TEXT NOT NULL,
subtitle_id TEXT,
queue_index INTEGER,
playlist_id TEXT,
playlist_indices TEXT,
resolution TEXT,
ext TEXT,
abr REAL,
vbr REAL,
acodec TEXT,
vcodec TEXT,
dynamic_range TEXT,
process_id INTEGER,
status TEXT,
item TEXT,
progress REAL,
total INTEGER,
downloaded INTEGER,
speed REAL,
eta INTEGER,
filepath TEXT,
filetype TEXT,
filesize INTEGER,
output_format TEXT,
embed_metadata INTEGER NOT NULL DEFAULT 0,
embed_thumbnail INTEGER NOT NULL DEFAULT 0,
square_crop_thumbnail INTEGER NOT NULL DEFAULT 0,
sponsorblock_remove TEXT,
sponsorblock_mark TEXT,
use_aria2 INTEGER NOT NULL DEFAULT 0,
custom_command TEXT,
queue_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES video_info (video_id),
FOREIGN KEY (playlist_id) REFERENCES playlist_info (playlist_id)
);
-- Copy all data from original table to temporary table with default values for new columns
INSERT INTO downloads_temp SELECT
id, download_id, download_status, video_id, format_id, subtitle_id,
queue_index, playlist_id,
CAST(playlist_index AS TEXT), -- Convert INTEGER playlist_index to TEXT playlist_indices
resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status,
CASE WHEN playlist_id IS NOT NULL THEN '1/1' ELSE NULL END, -- item
progress, total, downloaded, speed, eta,
filepath, filetype, filesize,
output_format, embed_metadata, embed_thumbnail,
0, -- square_crop_thumbnail
sponsorblock_remove, sponsorblock_mark, use_aria2,
custom_command, queue_config, created_at, updated_at
FROM downloads;
-- Remove existing triggers
DROP TRIGGER IF EXISTS update_downloads_updated_at;
-- Drop the original table
DROP TABLE downloads;
-- Rename temporary table to original name
ALTER TABLE downloads_temp RENAME TO downloads;
-- Create trigger for updating updated_at timestamp
CREATE TRIGGER IF NOT EXISTS update_downloads_updated_at
AFTER UPDATE ON downloads
FOR EACH ROW
BEGIN
UPDATE downloads SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Add indexes to improve query performance
CREATE INDEX IF NOT EXISTS idx_downloads_video_id ON downloads(video_id);
CREATE INDEX IF NOT EXISTS idx_downloads_playlist_id ON downloads(playlist_id);
CREATE INDEX IF NOT EXISTS idx_downloads_status_updated ON downloads(download_status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_downloads_id_desc ON downloads(id DESC);
",
kind: MigrationKind::Up,
}] }]
} }

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "NeoDLP", "productName": "NeoDLP",
"mainBinaryName": "neodlp", "mainBinaryName": "neodlp",
"version": "0.3.3", "version": "0.4.1",
"identifier": "com.neosubhamoy.neodlp", "identifier": "com.neosubhamoy.neodlp",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -10,8 +10,9 @@
"windows": [ "windows": [
{ {
"title": "NeoDLP", "title": "NeoDLP",
"width": 1067, "width": 1080,
"height": 605, "height": 680,
"decorations": false,
"visible": false "visible": false
} }
], ],
@@ -28,6 +29,7 @@
"targets": ["deb", "rpm"], "targets": ["deb", "rpm"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"licenseFile": "../LICENSE", "licenseFile": "../LICENSE",
"category": "Utility",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -38,8 +40,12 @@
"externalBin": [ "externalBin": [
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
},
"linux": { "linux": {
"deb": { "deb": {
"depends": ["ffmpeg"], "depends": ["ffmpeg"],

View File

@@ -0,0 +1,51 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "NeoDLP",
"width": 1080,
"height": 680,
"decorations": false,
"visible": false
}
],
"security": {
"csp": null,
"capabilities": [
"default",
"shell-scope"
]
}
},
"bundle": {
"active": true,
"targets": ["deb"],
"createUpdaterArtifacts": true,
"licenseFile": "../LICENSE",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/aria2c",
"binaries/deno",
"binaries/neodlp-pot"
],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}
}
}

View File

@@ -10,8 +10,9 @@
"windows": [ "windows": [
{ {
"title": "NeoDLP", "title": "NeoDLP",
"width": 1067, "width": 1080,
"height": 605, "height": 680,
"decorations": false,
"visible": false "visible": false
} }
], ],
@@ -28,6 +29,7 @@
"targets": ["deb", "rpm", "appimage"], "targets": ["deb", "rpm", "appimage"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"licenseFile": "../LICENSE", "licenseFile": "../LICENSE",
"category": "Utility",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -38,8 +40,12 @@
"externalBin": [ "externalBin": [
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": {
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
},
"linux": { "linux": {
"deb": { "deb": {
"depends": ["ffmpeg"], "depends": ["ffmpeg"],

View File

@@ -39,13 +39,15 @@
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost", "target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json", "resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json", "resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist" "resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"macOS": { "macOS": {
"providerShortName": "neosubhamoy" "providerShortName": "neosubhamoy"

View File

@@ -39,13 +39,15 @@
"binaries/yt-dlp", "binaries/yt-dlp",
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost", "target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json", "resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json", "resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist" "resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"macOS": { "macOS": {
"providerShortName": "neosubhamoy" "providerShortName": "neosubhamoy"

View File

@@ -10,8 +10,9 @@
"windows": [ "windows": [
{ {
"title": "NeoDLP", "title": "NeoDLP",
"width": 1067, "width": 1080,
"height": 605, "height": 680,
"decorations": false,
"visible": false "visible": false
} }
], ],
@@ -40,12 +41,14 @@
"binaries/ffmpeg", "binaries/ffmpeg",
"binaries/ffprobe", "binaries/ffprobe",
"binaries/aria2c", "binaries/aria2c",
"binaries/deno" "binaries/deno",
"binaries/neodlp-pot"
], ],
"resources": { "resources": {
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe", "target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
"resources/msghost-manifest/windows/chrome.json": "chrome.json", "resources/msghost-manifest/windows/chrome.json": "chrome.json",
"resources/msghost-manifest/windows/firefox.json": "firefox.json" "resources/msghost-manifest/windows/firefox.json": "firefox.json",
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
}, },
"windows": { "windows": {
"wix": { "wix": {

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { VideoFormat } from "@/types/video" import { VideoFormat } from "@/types/video"
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils" import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils"
@@ -57,8 +57,8 @@ const FormatSelectionGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative w-full rounded-lg border-2 border-border bg-card px-3 py-2 shadow-sm transition-all", "relative w-full rounded-lg border-2 border-border bg-background px-3 py-2 shadow-sm transition-all",
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70", "data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
"hover:bg-muted/70", "hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
className className
@@ -79,4 +79,4 @@ const FormatSelectionGroupItem = React.forwardRef<
}) })
FormatSelectionGroupItem.displayName = "FormatSelectionGroupItem" FormatSelectionGroupItem.displayName = "FormatSelectionGroupItem"
export { FormatSelectionGroup, FormatSelectionGroupItem } export { FormatSelectionGroup, FormatSelectionGroupItem }

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
import { VideoFormat } from "@/types/video";
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils";
import { Music, Video, File } from "lucide-react";
const FormatToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
>({
size: "default",
variant: "default",
toggleType: "multiple",
});
type FormatToggleGroupProps =
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
export const FormatToggleGroup = React.forwardRef<
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
FormatToggleGroupProps
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
if (type === "single") {
return (
<ToggleGroupPrimitive.Root
ref={ref}
type="single"
className={cn("flex flex-col gap-2", className)}
{...(props as any)}
>
<FormatToggleGroupContext.Provider value={{ variant, size, toggleType: "single" }}>
{children}
</FormatToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
return (
<ToggleGroupPrimitive.Root
ref={ref}
type="multiple"
className={cn("flex flex-col gap-2", className)}
{...(props as any)}
>
<FormatToggleGroupContext.Provider value={{ variant, size, toggleType: "multiple" }}>
{children}
</FormatToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
});
FormatToggleGroup.displayName = "FormatToggleGroup";
export const FormatToggleGroupItem = React.forwardRef<
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> & {
format: VideoFormat
}
>(({ className, children, variant, size, format, value, ...props }, ref) => {
const determineFileTypeIcon = (format: VideoFormat) => {
const fileFormat = determineFileType(/*format.video_ext, format.audio_ext,*/ format.vcodec, format.acodec)
switch (fileFormat) {
case 'video+audio':
return (
<span className="absolute flex items-center right-2 bottom-2">
<Video className="w-3 h-3 mr-2" />
<Music className="w-3 h-3" />
</span>
)
case 'video':
return (
<Video className="w-3 h-3 absolute right-2 bottom-2" />
)
case 'audio':
return (
<Music className="w-3 h-3 absolute right-2 bottom-2" />
)
default:
return (
<File className="w-3 h-3 absolute right-2 bottom-2" />
)
}
}
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full p-2 rounded-lg border-2 border-border bg-background px-3 py-2 shadow-sm transition-all",
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
"data-[state=on]:border-primary",
className
)}
value={value}
{...props}
>
<div className="flex flex-col items-start text-start gap-1">
<h5 className="text-sm">{format.format}</h5>
<p className="text-muted-foreground text-xs">{format.filesize_approx ? formatFileSize(format.filesize_approx) : 'unknown'} {format.tbr ? formatBitrate(format.tbr) : 'unknown'}</p>
<p className="text-muted-foreground text-xs">{format.ext ? format.ext.toUpperCase() : 'unknown'} {
((format.vcodec && format.vcodec !== 'none') || (format.acodec && format.acodec !== 'none')) && (
`(${format.vcodec && format.vcodec !== 'none' ? formatCodec(format.vcodec) : ''}${format.vcodec && format.vcodec !== 'none' && format.acodec && format.acodec !== 'none' ? ' ' : ''}${format.acodec && format.acodec !== 'none' ? formatCodec(format.acodec) : ''})`
)}</p>
{determineFileTypeIcon(format)}
</div>
</ToggleGroupPrimitive.Item>
);
});
FormatToggleGroupItem.displayName = "FormatToggleGroupItem";

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress" import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -33,4 +33,4 @@ const IndeterminateProgress = React.forwardRef<
)); ));
IndeterminateProgress.displayName = ProgressPrimitive.Root.displayName; IndeterminateProgress.displayName = ProgressPrimitive.Root.displayName;
export { IndeterminateProgress }; export { IndeterminateProgress };

View File

@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { type VariantProps } from "class-variance-authority" import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -56,4 +56,4 @@ const ToggleGroupItem = React.forwardRef<
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem } export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,110 @@
import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
interface NumberInputProps
extends Omit<React.ComponentProps<"input">, "type" | "onChange" | "value"> {
value?: number
defaultValue?: number
min?: number
max?: number
step?: number
onChange?: (value: number) => void
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
className,
value: controlledValue,
defaultValue = 0,
min = -Infinity,
max = Infinity,
step = 1,
onChange,
disabled,
readOnly,
...props
},
ref
) => {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const isControlled = controlledValue !== undefined
const currentValue = isControlled ? controlledValue : internalValue
const updateValue = (newValue: number) => {
const clamped = Math.min(max, Math.max(min, newValue))
if (!isControlled) {
setInternalValue(clamped)
}
onChange?.(clamped)
}
const handleIncrement = () => {
if (!disabled && !readOnly) {
updateValue(currentValue + step)
}
}
const handleDecrement = () => {
if (!disabled && !readOnly) {
updateValue(currentValue - step)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parseFloat(e.target.value)
if (!isNaN(parsed)) {
updateValue(parsed)
} else if (e.target.value === "" || e.target.value === "-") {
if (!isControlled) {
setInternalValue(0)
}
}
}
return (
<div className={cn("relative flex items-center", className)}>
<Input
type="number"
ref={ref}
value={currentValue}
onChange={handleInputChange}
min={min}
max={max}
step={step}
disabled={disabled}
readOnly={readOnly}
className="pr-16 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield] focus-visible:ring-0"
{...props}
/>
<div className="absolute right-0 flex h-full items-center">
<button
type="button"
onClick={handleDecrement}
disabled={disabled || readOnly || currentValue <= min}
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors border-x"
aria-label="Decrement"
tabIndex={-1}
>
<Minus className="size-3.5" />
</button>
<button
type="button"
onClick={handleIncrement}
disabled={disabled || readOnly || currentValue >= max}
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors rounded-r-md"
aria-label="Increment"
tabIndex={-1}
>
<Plus className="size-3.5" />
</button>
</div>
</div>
)
}
)
NumberInput.displayName = "NumberInput"
export { NumberInput }

View File

@@ -0,0 +1,81 @@
import { Paginated } from "@/types/download";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
export default function PaginationBar({
paginatedData,
setPage,
}: {
paginatedData: Paginated;
setPage: (page: number) => void;
}) {
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage(paginatedData.prev_page ?? paginatedData.first_page)}
aria-disabled={!paginatedData.prev_page}
className={!paginatedData.prev_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{paginatedData.pages.map((link, index, array) => {
const currentPage = paginatedData.current_page;
const pageNumber = link.page!;
// Show first page, last page, current page, and 2 pages around current
const showPage =
pageNumber === 1 ||
pageNumber === paginatedData.last_page ||
Math.abs(pageNumber - currentPage) <= 1;
// Show ellipsis if there's a gap
const prevVisiblePage = array
.slice(0, index)
.reverse()
.find((prevLink) => {
const prevPageNum = prevLink.page!;
return (
prevPageNum === 1 ||
prevPageNum === paginatedData.last_page ||
Math.abs(prevPageNum - currentPage) <= 1
);
})?.page;
const showEllipsis = showPage && prevVisiblePage && pageNumber - prevVisiblePage > 1;
if (!showPage) return null;
return (
<div key={link.page} className="contents">
{showEllipsis && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{showPage && (
<PaginationItem>
<PaginationLink
className="cursor-pointer"
onClick={() => setPage(link.page)}
isActive={link.active}
>
{link.label}
</PaginationLink>
</PaginationItem>
)}
</div>
);
})}
<PaginationItem>
<PaginationNext
onClick={() => setPage(paginatedData.next_page ?? paginatedData.last_page)}
aria-disabled={!paginatedData.next_page}
className={!paginatedData.next_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View File

@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { RawVideoInfo } from "@/types/video" import { RawVideoInfo } from "@/types/video"
import { formatDurationString} from "@/utils" import { formatDurationString} from "@/utils"
@@ -35,8 +35,8 @@ const PlaylistSelectionGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative w-full rounded-lg border-2 border-border bg-card p-2 shadow-sm transition-all", "relative w-full rounded-lg border-2 border-border bg-background p-2 shadow-sm transition-all",
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70", "data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
"hover:bg-muted/70", "hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
className className
@@ -44,28 +44,28 @@ const PlaylistSelectionGroupItem = React.forwardRef<
{...props} {...props}
> >
<div className="flex gap-2 w-full relative"> <div className="flex gap-2 w-full relative">
<div className="w-[7rem] xl:w-[10rem]"> <div className="w-28 xl:w-40">
<AspectRatio <AspectRatio
ratio={16 / 9} ratio={16 / 9}
className={clsx( className={clsx(
"w-full rounded overflow-hidden border border-border", "w-full rounded overflow-hidden border border-border",
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative" video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
)} )}
> >
<ProxyImage <ProxyImage
src={video.thumbnail} src={video.thumbnail}
alt="thumbnail" alt="thumbnail"
className={clsx( className={clsx(
video.aspect_ratio && video.aspect_ratio === 0.56 && video.aspect_ratio && video.aspect_ratio === 0.56 &&
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
)} )}
/> />
</AspectRatio> </AspectRatio>
</div> </div>
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start"> <div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3> <h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p> <p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-xs text-muted-foreground flex items-center pr-3"> <span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/> <Clock className="w-4 h-4 mr-2"/>
@@ -79,4 +79,4 @@ const PlaylistSelectionGroupItem = React.forwardRef<
}) })
PlaylistSelectionGroupItem.displayName = "PlaylistSelectionGroupItem" PlaylistSelectionGroupItem.displayName = "PlaylistSelectionGroupItem"
export { PlaylistSelectionGroup, PlaylistSelectionGroupItem } export { PlaylistSelectionGroup, PlaylistSelectionGroupItem }

View File

@@ -1,9 +1,8 @@
import * as React from "react"; import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { type VariantProps } from "class-variance-authority"; import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle"; import { toggleVariants } from "@/components/ui/toggle";
import { Checkbox } from "@/components/ui/checkbox";
import { AspectRatio } from "@/components/ui/aspect-ratio"; import { AspectRatio } from "@/components/ui/aspect-ratio";
import { ProxyImage } from "@/components/custom/proxyImage"; import { ProxyImage } from "@/components/custom/proxyImage";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
@@ -11,7 +10,6 @@ import clsx from "clsx";
import { formatDurationString } from "@/utils"; import { formatDurationString } from "@/utils";
import { RawVideoInfo } from "@/types/video"; import { RawVideoInfo } from "@/types/video";
// Create a context to share toggle group props
const PlaylistToggleGroupContext = React.createContext< const PlaylistToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" } VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
>({ >({
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
toggleType: "multiple", toggleType: "multiple",
}); });
// Helper type for the PlaylistToggleGroup type PlaylistToggleGroupProps =
type PlaylistToggleGroupProps = | (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void }) VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> & | (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void }); VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
// Main PlaylistToggleGroup component with proper type handling
export const PlaylistToggleGroup = React.forwardRef< export const PlaylistToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
PlaylistToggleGroupProps PlaylistToggleGroupProps
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => { >(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
// Pass props based on the type
if (type === "single") { if (type === "single") {
return ( return (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
@@ -47,7 +42,7 @@ export const PlaylistToggleGroup = React.forwardRef<
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
); );
} }
return ( return (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
ref={ref} ref={ref}
@@ -63,106 +58,48 @@ export const PlaylistToggleGroup = React.forwardRef<
}); });
PlaylistToggleGroup.displayName = "PlaylistToggleGroup"; PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
// Rest of your component remains the same
// PlaylistToggleGroupItem component with checkbox and item layout
export const PlaylistToggleGroupItem = React.forwardRef< export const PlaylistToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>, React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> & { VariantProps<typeof toggleVariants> & {
video: RawVideoInfo; video: RawVideoInfo;
} }
>(({ className, children, variant, size, video, value, ...props }, ref) => { >(({ className, children, variant, size, video, value, ...props }, ref) => {
const [isHovered, setIsHovered] = React.useState(false);
const [checked, setChecked] = React.useState(false);
// Instead of a ref + useEffect approach
const [itemElement, setItemElement] = React.useState<HTMLButtonElement | null>(null);
// Handle checkbox click separately by simulating a click on the parent item
const handleCheckboxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
// Manually trigger the item's click to toggle selection
if (itemElement) {
// This simulates a click on the toggle item itself
itemElement.click();
}
};
// Use an effect that triggers when itemElement changes
React.useEffect(() => {
if (itemElement) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-state') {
setChecked(itemElement.getAttribute('data-state') === 'on');
}
});
});
setChecked(itemElement.getAttribute('data-state') === 'on');
observer.observe(itemElement, { attributes: true });
return () => observer.disconnect();
}
}, [itemElement]);
return ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
ref={(el) => { ref={ref}
// Handle both our ref and the forwarded ref
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
setItemElement(el);
}}
className={cn( className={cn(
"flex w-full p-2 rounded-md transition-colors border-2 border-border", "flex w-full p-2 rounded-lg transition-colors border-2 border-border",
"hover:bg-muted/50 data-[state=on]:bg-muted/70", "hover:bg-muted/70 data-[state=on]:bg-primary/10",
"data-[state=on]:border-primary", "data-[state=on]:border-primary",
className className
)} )}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
value={value} value={value}
{...props} {...props}
> >
<div className="flex gap-2 w-full relative"> <div className="flex gap-2 w-full relative">
<div className="absolute top-2 left-2 z-10"> <div className="w-28 xl:w-40">
<Checkbox <AspectRatio
checked={checked} ratio={16 / 9}
onClick={handleCheckboxClick}
className={cn(
"transition-opacity",
isHovered || checked ? "opacity-100" : "opacity-0"
)}
/>
</div>
<div className="w-[7rem] xl:w-[10rem]">
<AspectRatio
ratio={16 / 9}
className={clsx( className={clsx(
"w-full rounded overflow-hidden border border-border", "w-full rounded overflow-hidden border border-border",
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative" video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
)} )}
> >
<ProxyImage <ProxyImage
src={video.thumbnail} src={video.thumbnail}
alt="thumbnail" alt="thumbnail"
className={clsx( className={clsx(
video.aspect_ratio && video.aspect_ratio === 0.56 && video.aspect_ratio && video.aspect_ratio === 0.56 &&
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
)} )}
/> />
</AspectRatio> </AspectRatio>
</div> </div>
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start"> <div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3> <h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p> <p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-xs text-muted-foreground flex items-center pr-3"> <span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/> <Clock className="w-4 h-4 mr-2"/>
@@ -174,4 +111,4 @@ export const PlaylistToggleGroupItem = React.forwardRef<
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>
); );
}); });
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem"; PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";

View File

@@ -23,7 +23,7 @@ export const SlidingButton = ({
return ( return (
<Tag <Tag
className={cn( className={cn(
"px-4 py-2 rounded-md bg-black dark:bg-white dark:text-black text-white text-center relative overflow-hidden cursor-pointer flex justify-center", "px-4 py-2 rounded-md bg-primary text-primary-foreground text-center relative overflow-hidden cursor-pointer flex justify-center",
`group/sliding-button`, `group/sliding-button`,
className className
)} )}
@@ -41,7 +41,7 @@ export const SlidingButton = ({
</span> </span>
<div <div
className={cn( className={cn(
'flex items-center justify-center absolute inset-0 transition duration-500 text-white z-20', 'flex items-center justify-center absolute inset-0 transition duration-500 text-primary-foreground z-20',
`-translate-x-60 group-hover/sliding-button:translate-x-0` `-translate-x-60 group-hover/sliding-button:translate-x-0`
)} )}
> >
@@ -49,4 +49,4 @@ export const SlidingButton = ({
</div> </div>
</Tag> </Tag>
); );
}; };

View File

@@ -2,20 +2,21 @@ import { useLocation } from "react-router-dom";
import { isActive } from "@/utils"; import { isActive } from "@/utils";
import { config } from "@/config"; import { config } from "@/config";
import { useSettingsPageStatesStore } from "@/services/store"; import { useSettingsPageStatesStore } from "@/services/store";
import { Github, Globe } from "lucide-react"; import { Github, Globe, Heart } from "lucide-react";
import { IndianFlagLogo } from "@/components/icons/india";
export default function Footer() { export default function Footer() {
const location = useLocation(); const location = useLocation();
const isSettingsPage = isActive("/settings", location.pathname, true); const isSettingsPage = isActive("/settings", location.pathname, true);
const appVersion = useSettingsPageStatesStore(state => state.appVersion); const appVersion = useSettingsPageStatesStore(state => state.appVersion);
return ( return (
<> <>
{isSettingsPage ? ( {isSettingsPage ? (
<div className="flex items-center justify-between p-4 border-t border-border"> <div className="flex items-center justify-between p-4 border-t border-border">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm">{config.appName} v{appVersion} - &copy; {new Date().getFullYear()} &nbsp;|&nbsp; <a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank">MIT License</a></span> <span className="text-sm">{config.appName} v{appVersion} &copy; 2025 - {new Date().getFullYear()} &nbsp;|&nbsp; <a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank">MIT License</a></span>
<span className="text-xs text-muted-foreground">Made with by <a href={config.appAuthorUrl} target="_blank">{config.appAuthor}</a></span> <span className="text-xs text-muted-foreground">Proudly Made with <Heart className="inline size-3 mb-0.5 fill-primary stroke-primary"/> in India <IndianFlagLogo className="inline size-full w-4 ml-0.5 mb-[0.1rem]" /></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a href={config.appHomepage} target="_blank" className="text-sm text-muted-foreground hover:text-foreground" title="Homepage"> <a href={config.appHomepage} target="_blank" className="text-sm text-muted-foreground hover:text-foreground" title="Homepage">

View File

@@ -0,0 +1,12 @@
export function CloseIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,27 @@
export function IndianFlagLogo({ className }: { className?: string }) {
return (
<svg width="1024" height="1024" viewBox="-45 -30 90 60" fill="#07038D" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" className={className}>
<path fill="#FFF" d="m-45-30h90v60h-90z"/>
<path fill="#FF6820" d="m-45-30h90v20h-90z"/>
<path fill="#046A38" d="m-45 10h90v20h-90z"/>
<circle r="9.25"/>
<circle fill="#FFF" r="8"/>
<circle r="1.6"/>
<g id="d">
<g id="c">
<g id="b">
<g id="a">
<path d="m0-8 .3 4.81409L0-.80235-.3-3.18591z"/>
<circle transform="rotate(7.5)" r="0.35" cy="-8"/>
</g>
<use xlinkHref="#a" transform="scale(-1)"/>
</g>
<use xlinkHref="#b" transform="rotate(15)"/>
</g>
<use xlinkHref="#c" transform="rotate(30)"/>
</g>
<use xlinkHref="#d" transform="rotate(60)"/>
<use xlinkHref="#d" transform="rotate(120)"/>
</svg>
);
}

View File

@@ -0,0 +1,7 @@
export function MaximizeIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
);
}

View File

@@ -0,0 +1,7 @@
export function MinimizeIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
);
}

View File

@@ -6,10 +6,10 @@ export function NeoDlpLogo({ className }: { className?: string }) {
<rect x="355" y="222" width="313" height="346" rx="25" fill="#FAFAFA"/> <rect x="355" y="222" width="313" height="346" rx="25" fill="#FAFAFA"/>
<defs> <defs>
<linearGradient id="paint0_linear_10_2" x1="129.5" y1="148.5" x2="921" y2="863" gradientUnits="userSpaceOnUse"> <linearGradient id="paint0_linear_10_2" x1="129.5" y1="148.5" x2="921" y2="863" gradientUnits="userSpaceOnUse">
<stop stopColor="#4444FF"/> <stop stopColor="var(--logo-stop-color-1)"/>
<stop offset="1" stopColor="#FF43D0"/> <stop offset="1" stopColor="var(--logo-stop-color-2)"/>
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
) )
} }

View File

@@ -0,0 +1,10 @@
export function UnmaximizeIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
);
}

View File

@@ -1,56 +1,99 @@
import { useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
import { getRouteName } from "@/utils"; import { getRouteName } from "@/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Terminal } from "lucide-react"; import { BrushCleaning, Check, Copy, Terminal } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useLogger } from "@/helpers/use-logger"; import { useLogger } from "@/helpers/use-logger";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import TitleBar from "@/components/titlebar";
import { platform } from "@tauri-apps/plugin-os";
export default function Navbar() { export default function Navbar() {
const [copied, setCopied] = useState(false);
const location = useLocation(); const location = useLocation();
const logs = useLogger().getLogs(); const currentPlatform = platform();
const logger = useLogger();
const logs = logger.getLogs();
const logText = logs.map(log => `${new Date(log.timestamp).toLocaleTimeString()} [${log.level.toUpperCase()}] ${log.context}: ${log.message}`).join('\n');
const handleCopyLogs = async () => {
await writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return ( return (
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-backdrop-filter:bg-background/60 border-b z-50"> <div className="sticky top-0 z-50">
<div className="flex justify-center"> {currentPlatform === "windows" || currentPlatform === "linux" ? (
<SidebarTrigger /> <TitleBar />
<h1 className="text-lg text-primary font-semibold ml-4">{getRouteName(location.pathname)}</h1> ) : (
</div> null
<div className="flex justify-center items-center"> )}
<Dialog> <nav className="flex justify-between items-center py-3 px-4 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<Tooltip> <div className="flex justify-center">
<TooltipTrigger asChild> <SidebarTrigger />
<DialogTrigger asChild> <h1 className="text-lg font-semibold ml-4">{getRouteName(location.pathname)}</h1>
<Button variant="outline" size="icon"> </div>
<Terminal /> <div className="flex justify-center items-center">
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Terminal />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Logs</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-150">
<DialogHeader>
<DialogTitle>Log Viewer</DialogTitle>
<DialogDescription>Monitor real-time app session logs (latest on top)</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 p-2 max-h-75 overflow-y-scroll overflow-x-hidden bg-muted">
{logs.length === 0 ? (
<p className="text-sm text-muted-foreground">NO LOGS TO SHOW!</p>
) : (
logs.slice().reverse().map((log, index) => (
<div key={index} className={`flex flex-col ${log.level === 'error' ? 'text-red-500' : log.level === 'warning' ? 'text-amber-500' : log.level === 'debug' ? 'text-sky-500' : log.level === 'progress' ? 'text-emerald-500' : 'text-foreground'}`}>
<p className="text-xs"><strong>{new Date(log.timestamp).toLocaleTimeString()}</strong> [{log.level.toUpperCase()}] <em>{log.context}</em> :</p>
<p className="text-xs font-mono break-all">{log.message}</p>
</div>
))
)}
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={logs.length === 0}
onClick={() => logger.clearLogs()}
>
<BrushCleaning className="size-4" />
Clear Logs
</Button> </Button>
</DialogTrigger> <Button
</TooltipTrigger> className="transition-all duration-300"
<TooltipContent> disabled={logs.length === 0}
<p>Logs</p> onClick={() => handleCopyLogs()}
</TooltipContent> >
</Tooltip> {copied ? (
<DialogContent className="sm:max-w-[600px]"> <Check className="size-4" />
<DialogHeader> ) : (
<DialogTitle>Log Viewer</DialogTitle> <Copy className="size-4" />
<DialogDescription>Monitor real-time app session logs (latest on top)</DialogDescription> )}
</DialogHeader> Copy Logs
<div className="flex flex-col gap-2 p-2 max-h-[300px] overflow-y-scroll overflow-x-hidden bg-muted"> </Button>
{logs.length === 0 ? ( </DialogFooter>
<p className="text-sm text-muted-foreground">NO LOGS TO SHOW!</p> </DialogContent>
) : ( </Dialog>
logs.slice().reverse().map((log, index) => ( </div>
<div key={index} className={`flex flex-col ${log.level === 'error' ? 'text-red-500' : log.level === 'warning' ? 'text-amber-500' : log.level === 'debug' ? 'text-sky-500' : log.level === 'progress' ? 'text-emerald-500' : 'text-foreground'}`}> </nav>
<p className="text-xs"><strong>{new Date(log.timestamp).toLocaleTimeString()}</strong> [{log.level.toUpperCase()}] <em>{log.context}</em> :</p> </div>
<p className="text-xs font-mono break-all">{log.message}</p>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</div>
</nav>
) )
} }

View File

@@ -0,0 +1,503 @@
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { formatBitrate, formatFileSize } from "@/utils";
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
import { useEffect, useRef } from "react";
import { RawVideoInfo, VideoFormat } from "@/types/video";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
interface DownloadConfigDialogProps {
selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown";
}
interface BottomBarProps {
videoMetadata: RawVideoInfo;
selectedFormat: VideoFormat | undefined;
selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown";
selectedVideoFormat: VideoFormat | undefined;
selectedAudioFormats: VideoFormat[] | undefined;
containerRef: React.RefObject<HTMLDivElement | null>;
}
function DownloadConfigDialog({ selectedFormatFileType }: DownloadConfigDialogProps) {
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab);
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
const embedVideoThumbnail = useSettingsPageStatesStore(state => state.settings.embed_video_thumbnail);
const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
const isCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 0;
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={!selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !isCombineableAudioSelected))}
>
<Settings2 className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-112.5">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
<DialogDescription>Tweak this download's configurations</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 max-h-75 overflow-y-scroll overflow-x-hidden no-scrollbar">
<Tabs
className=""
value={activeDownloadConfigurationTab}
onValueChange={(tab) => setActiveDownloadConfigurationTab(tab)}
>
<TabsList>
<TabsTrigger value="options">Options</TabsTrigger>
<TabsTrigger value="commands">Commands</TabsTrigger>
</TabsList>
<TabsContent value="options">
{useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon className="size-4 stroke-primary" />
<AlertTitle className="text-sm">Options Unavailable!</AlertTitle>
<AlertDescription className="text-xs">
You cannot use these options when custom commands are enabled. To use these options, disable custom commands from Settings.
</AlertDescription>
</Alert>
) : null}
<div className="video-format">
<Label className="text-xs mb-3 mt-2">Output Format ({(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? 'Video' : selectedFormatFileType && selectedFormatFileType === 'audio' ? 'Audio' : 'Unknown'})</Label>
{(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap my-2"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="v-auto" />
<Label htmlFor="v-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="v-mp4" />
<Label htmlFor="v-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="v-webm" />
<Label htmlFor="v-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="v-mkv" />
<Label htmlFor="v-mkv">MKV</Label>
</div>
</RadioGroup>
) : selectedFormatFileType && selectedFormatFileType === 'audio' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap my-2"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="a-auto" />
<Label htmlFor="a-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="a-m4a" />
<Label htmlFor="a-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="a-opus" />
<Label htmlFor="a-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="a-mp3" />
<Label htmlFor="a-mp3">MP3</Label>
</div>
</RadioGroup>
) : (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap my-2"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="u-auto" />
<Label htmlFor="u-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="u-mp4" />
<Label htmlFor="u-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="u-webm" />
<Label htmlFor="u-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="u-mkv" />
<Label htmlFor="u-mkv">MKV</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="u-m4a" />
<Label htmlFor="u-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="u-opus" />
<Label htmlFor="u-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="u-mp3" />
<Label htmlFor="u-mp3">MP3</Label>
</div>
</RadioGroup>
)}
</div>
<div className="sponsorblock">
<Label className="text-xs my-3">Sponsorblock Mode</Label>
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap my-2"
value={downloadConfiguration.sponsorblock ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('sponsorblock', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="sb-auto" />
<Label htmlFor="sb-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="remove" id="sb-remove" />
<Label htmlFor="sb-remove">Remove</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mark" id="sb-mark" />
<Label htmlFor="sb-mark">Mark</Label>
</div>
</RadioGroup>
</div>
<div className="embeding-options">
<Label className="text-xs my-3">Embedding Options</Label>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-metadata"
checked={downloadConfiguration.embed_metadata !== null ? downloadConfiguration.embed_metadata : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoMetadata : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioMetadata : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_metadata', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-metadata">Embed Metadata</Label>
</div>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-thumbnail"
checked={downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoThumbnail : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_thumbnail', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-thumbnail">Embed Thumbnail</Label>
<div className="flex items-center gap-3 ml-4">
<Checkbox
id="square-crop-thumbnail"
checked={downloadConfiguration.square_crop_thumbnail !== null ? downloadConfiguration.square_crop_thumbnail : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('square_crop_thumbnail', checked)}
disabled={useCustomCommands || !(downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoThumbnail : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false)}
/>
<Label htmlFor="square-crop-thumbnail">Square Crop</Label>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="commands">
{!useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon className="size-4 stroke-primary" />
<AlertTitle className="text-sm">Enable Custom Commands!</AlertTitle>
<AlertDescription className="text-xs">
To run custom commands for downloads, please enable it from the Settings.
</AlertDescription>
</Alert>
) : null}
<div className="custom-commands">
<Label className="text-xs mb-3 mt-2">Run Custom Command</Label>
{customCommands.length === 0 ? (
<p className="text-sm text-muted-foreground">NO CUSTOM COMMAND TEMPLATE ADDED YET!</p>
) : (
<RadioGroup
orientation="vertical"
className="flex flex-col gap-2 my-2"
disabled={!useCustomCommands}
value={downloadConfiguration.custom_command}
onValueChange={(value) => setDownloadConfigurationKey('custom_command', value)}
>
{customCommands.map((command) => (
<div className="flex items-center gap-3" key={command.id}>
<RadioGroupItem value={command.id} id={`cmd-${command.id}`} />
<Label htmlFor={`cmd-${command.id}`}>{command.label}</Label>
</div>
))}
</RadioGroup>
)}
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}
export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileType, selectedVideoFormat, selectedAudioFormats, containerRef }: BottomBarProps) {
const { startDownload } = useAppContext();
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
const useSponsorblock = useSettingsPageStatesStore(state => state.settings.use_sponsorblock);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const bottomBarRef = useRef<HTMLDivElement>(null);
const isPlaylist = videoMetadata._type === 'playlist';
const isMultiplePlaylistItems = isPlaylist && selectedPlaylistVideos.length > 1;
const isCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 0 && selectedAudioFormats && selectedAudioFormats.length > 0;
const isMultipleCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 1 && selectedAudioFormats && selectedAudioFormats.length > 1;
let selectedFormatExtensionMsg = 'Auto - unknown';
if (activeDownloadModeTab === 'combine') {
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${downloadConfiguration.output_format.toUpperCase()}`;
} else if (videoFormat !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`;
} else if (isCombineableAudioSelected && selectedVideoFormat?.ext) {
if (isMultipleCombineableAudioSelected) {
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormats.length} Audio`;
} else {
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormats[0].ext.toUpperCase()}`;
}
} else {
selectedFormatExtensionMsg = `Combined - unknown`;
}
} else if (selectedFormat?.ext) {
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : audioFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'unknown' && downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Forced - ${downloadConfiguration.output_format.toUpperCase()}`;
} else {
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
}
}
let selectedFormatResolutionMsg = 'unknown';
let totalTbr = 0;
if (activeDownloadModeTab === 'combine') {
if (isCombineableAudioSelected) {
if (isMultipleCombineableAudioSelected) {
const totalAudioTbr = selectedAudioFormats.reduce((acc, format) => acc + (format.tbr ?? 0), 0);
totalTbr = (selectedVideoFormat?.tbr ?? 0) + totalAudioTbr;
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${formatBitrate(totalAudioTbr)}`;
} else {
totalTbr = (selectedVideoFormat?.tbr ?? 0) + (selectedAudioFormats && selectedAudioFormats[0].tbr ? selectedAudioFormats[0].tbr : 0);
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${selectedAudioFormats && selectedAudioFormats[0].tbr ? formatBitrate(selectedAudioFormats[0].tbr) : 'unknown'}`;
}
} else {
totalTbr = selectedVideoFormat?.tbr ?? 0;
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + unknown`;
}
} else if (selectedFormat?.resolution) {
totalTbr = selectedFormat.tbr ?? 0;
selectedFormatResolutionMsg = selectedFormat.resolution;
}
let selectedFormatDynamicRangeMsg = '';
if (activeDownloadModeTab === 'combine') {
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' && selectedVideoFormat.dynamic_range !== 'auto' ? selectedVideoFormat.dynamic_range : '';
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' && selectedFormat.dynamic_range !== 'auto') {
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
}
let selectedFormatFileSizeMsg = 'unknown filesize';
let totalFilesize = 0;
if (activeDownloadModeTab === 'combine') {
if (isCombineableAudioSelected) {
if (isMultipleCombineableAudioSelected) {
totalFilesize = (selectedVideoFormat?.filesize_approx ?? 0) + selectedAudioFormats.reduce((acc, format) => acc + (format.filesize_approx ?? 0), 0);
selectedFormatFileSizeMsg = totalFilesize > 0 ? formatFileSize(totalFilesize) : 'unknown filesize';
} else {
totalFilesize = (selectedVideoFormat?.filesize_approx ?? 0) + (selectedAudioFormats && selectedAudioFormats[0].filesize_approx ? selectedAudioFormats[0].filesize_approx : 0);
selectedFormatFileSizeMsg = (selectedVideoFormat?.filesize_approx && selectedAudioFormats && selectedAudioFormats[0].filesize_approx) ? formatFileSize(selectedVideoFormat.filesize_approx + selectedAudioFormats[0].filesize_approx) : 'unknown filesize';
}
} else {
totalFilesize = selectedVideoFormat?.filesize_approx ?? 0;
selectedFormatFileSizeMsg = selectedVideoFormat?.filesize_approx ? formatFileSize(selectedVideoFormat.filesize_approx) : 'unknown filesize';
}
} else if (selectedFormat?.filesize_approx) {
totalFilesize = selectedFormat.filesize_approx;
selectedFormatFileSizeMsg = formatFileSize(selectedFormat.filesize_approx);
}
let selectedFormatFinalMsg = '';
if (activeDownloadModeTab === 'combine') {
if (selectedCombinableVideoFormat && selectedCombinableAudioFormats.length > 0) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Choose a video and audio streams to combine`;
}
} else {
if (selectedFormat) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Select a stream to download`;
}
}
useEffect(() => {
const updateBottomBarWidth = (): void => {
if (containerRef.current && bottomBarRef.current) {
bottomBarRef.current.style.width = `${containerRef.current.offsetWidth}px`;
const containerRect = containerRef.current.getBoundingClientRect();
bottomBarRef.current.style.left = `${containerRect.left}px`;
}
};
updateBottomBarWidth();
const resizeObserver = new ResizeObserver(() => {
updateBottomBarWidth();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
window.addEventListener('resize', updateBottomBarWidth);
window.addEventListener('scroll', updateBottomBarWidth);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateBottomBarWidth);
window.removeEventListener('scroll', updateBottomBarWidth);
};
}, []);
useEffect(() => {
useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options');
}, []);
return (
<div className="flex justify-between items-center gap-2 fixed bottom-0 right-0 p-4 w-full bg-background rounded-t-lg border-t border-border z-20" ref={bottomBarRef}>
<div className="flex items-center gap-4">
<div className="flex justify-center items-center p-3 rounded-md border border-border">
{activeDownloadModeTab === 'combine' && (
<Video className="w-4 h-4 stroke-primary" />
)}
{activeDownloadModeTab !== 'combine' && selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio') && (
<Video className="w-4 h-4 stroke-primary" />
)}
{activeDownloadModeTab !== 'combine' && selectedFormatFileType && selectedFormatFileType === 'audio' && (
<Music className="w-4 h-4 stroke-primary" />
)}
{activeDownloadModeTab !== 'combine' && ((!selectedFormatFileType) || (selectedFormatFileType && selectedFormatFileType !== 'video' && selectedFormatFileType !== 'audio' && selectedFormatFileType !== 'video+audio')) && (
<File className="w-4 h-4 stroke-primary" />
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? selectedPlaylistVideos.length === 1 ? videoMetadata.entries[Number(selectedPlaylistVideos[0]) - 1].title : `${selectedPlaylistVideos.length} Items` : 'Unknown' }</span>
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div>
</div>
<div className="flex items-center gap-2">
<DownloadConfigDialog selectedFormatFileType={selectedFormatFileType} />
<Button
onClick={async () => {
setIsStartingDownload(true);
try {
if (videoMetadata._type === 'playlist') {
await startDownload({
url: videoMetadata.original_url,
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormats.join('+')}` : selectedDownloadFormat,
downloadConfig: downloadConfiguration,
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
playlistItems: selectedPlaylistVideos.sort((a, b) => Number(a) - Number(b)).join(','),
overrideOptions: isMultiplePlaylistItems ? {
filesize: totalFilesize > 0 ? totalFilesize : undefined,
tbr: totalTbr > 0 ? totalTbr : undefined,
} : isMultipleCombineableAudioSelected ? {
filesize: totalFilesize > 0 ? totalFilesize : undefined,
tbr: totalTbr > 0 ? totalTbr : undefined,
} : undefined
});
} else if (videoMetadata._type === 'video') {
await startDownload({
url: videoMetadata.webpage_url,
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormats.join('+')}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfig: downloadConfiguration,
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
overrideOptions: isMultipleCombineableAudioSelected ? {
filesize: totalFilesize > 0 ? totalFilesize : undefined,
tbr: totalTbr > 0 ? totalTbr : undefined,
} : undefined
});
}
// toast({
// title: 'Download Initiated',
// description: 'Download initiated, it will start shortly.',
// });
} catch (error) {
console.error('Download failed to start:', error);
toast.error("Failed to Start Download", {
description: "There was an error initiating the download."
});
} finally {
setIsStartingDownload(false);
}
}}
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !isCombineableAudioSelected)) || (useCustomCommands && !downloadConfiguration.custom_command)}
>
{isStartingDownload ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Download
</>
) : (
'Start Download'
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,411 @@
import { useDownloaderPageStatesStore } from "@/services/store";
import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { RawVideoInfo, VideoFormat } from "@/types/video";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
import { getMergedBestFormat } from "@/utils";
import { Switch } from "@/components/ui/switch";
import { FormatToggleGroup, FormatToggleGroupItem } from "@/components/custom/formatToggleGroup";
import { Layout } from "react-resizable-panels";
interface PlaylistPreviewSelectionProps {
videoMetadata: RawVideoInfo;
}
interface SelectivePlaylistDownloadProps {
videoMetadata: RawVideoInfo;
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
combinedFormats: VideoFormat[] | undefined;
qualityPresetFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
interface CombinedPlaylistDownloadProps {
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
interface PlaylistDownloaderProps {
videoMetadata: RawVideoInfo;
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
combinedFormats: VideoFormat[] | undefined;
qualityPresetFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) {
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const totalVideos = videoMetadata.entries.filter((entry) => entry).length;
const allVideoIndices = videoMetadata.entries.filter((entry) => entry).map((entry) => entry.playlist_index.toString());
return (
<div className="flex flex-col w-full pr-4">
<div className="flex items-center justify-between mb-4 mt-2">
<h3 className="text-sm flex items-center gap-2">
<ListVideo className="w-4 h-4" />
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
</h3>
<div className="flex items-center space-x-2">
<Switch
id="select-all-videos"
checked={selectedPlaylistVideos.length === totalVideos && totalVideos > 0}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPlaylistVideos(allVideoIndices);
} else {
setSelectedPlaylistVideos(["1"]);
}
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormats([]);
resetDownloadConfiguration();
}}
disabled={totalVideos <= 1}
/>
</div>
</div>
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_creator || videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
<PlaylistToggleGroup
className="mb-2"
type="multiple"
value={selectedPlaylistVideos}
onValueChange={(value: string[]) => {
if (value.length > 0) {
setSelectedPlaylistVideos(value);
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormats([]);
resetDownloadConfiguration();
}
}}
>
{videoMetadata.entries.map((entry) => entry ? (
<PlaylistToggleGroupItem
key={entry.playlist_index}
value={entry.playlist_index.toString()}
video={entry}
/>
) : null)}
</PlaylistToggleGroup>
<div className="flex items-center text-muted-foreground">
<Info className="w-3 h-3 mr-2" />
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
</div>
<div className="spacer mb-12"></div>
</div>
</div>
);
}
function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectivePlaylistDownloadProps) {
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
return (
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
{subtitleLanguages && subtitleLanguages.length > 0 && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
<FormatSelectionGroup
value={selectedDownloadFormat}
onValueChange={(value) => {
setSelectedDownloadFormat(value);
// const currentlySelectedFormat = value === 'best' ? videoMetadata?.entries[Number(value) - 1].requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
// if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
// setSelectedSubtitles([]);
// }
resetDownloadConfiguration();
}}
>
<p className="text-xs">Suggested</p>
<div className="">
<FormatSelectionGroupItem
key="best"
value="best"
format={getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos) as VideoFormat}
/>
</div>
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
<>
<p className="text-xs">Quality Presets</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{qualityPresetFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{combinedFormats && combinedFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{combinedFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
<div className="spacer mb-12"></div>
</div>
);
}
function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages }: CombinedPlaylistDownloadProps) {
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
return (
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
<FormatToggleGroup
type="multiple"
variant="outline"
className="mb-2"
value={selectedCombinableAudioFormats}
onValueChange={(value: string[]) => {
setSelectedCombinableAudioFormats(value);
resetDownloadConfiguration();
}}
>
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatToggleGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatToggleGroup>
<FormatSelectionGroup
value={selectedCombinableVideoFormat}
onValueChange={(value) => {
setSelectedCombinableVideoFormat(value);
resetDownloadConfiguration();
}}
>
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
<Alert>
<AlertCircleIcon className="size-4 stroke-primary" />
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
<AlertDescription>
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
</AlertDescription>
</Alert>
)}
<div className="spacer mb-12"></div>
</div>
);
}
export function PlaylistDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: PlaylistDownloaderProps) {
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
const playlistPanelSizes = useDownloaderPageStatesStore((state) => state.playlistPanelSizes);
const setPlaylistPanelSizes = useDownloaderPageStatesStore((state) => state.setPlaylistPanelSizes);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
return (
<div className="flex">
<ResizablePanelGroup
orientation="horizontal"
className="w-full"
onLayoutChanged={(layout: Layout) => {
const firstPanelSize = layout[Object.keys(layout)[0]];
const secondPanelSize = layout[Object.keys(layout)[1]];
setPlaylistPanelSizes([firstPanelSize, secondPanelSize]);
}}
>
<ResizablePanel
defaultSize={`${playlistPanelSizes[0]}%`}
>
<PlaylistPreviewSelection videoMetadata={videoMetadata} />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={`${playlistPanelSizes[1]}%`}
>
<div className="flex flex-col w-full pl-4">
<Tabs
className=""
value={activeDownloadModeTab}
onValueChange={(tab) => {
setActiveDownloadModeTab(tab);
resetDownloadConfiguration();
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm flex items-center gap-2">
<DownloadCloud className="w-4 h-4" />
<span>Download Options</span>
</h3>
<TabsList>
<TabsTrigger value="selective">Selective</TabsTrigger>
<TabsTrigger value="combine">Combine</TabsTrigger>
</TabsList>
</div>
<TabsContent value="selective">
<SelectivePlaylistDownload
videoMetadata={videoMetadata}
audioOnlyFormats={audioOnlyFormats}
videoOnlyFormats={videoOnlyFormats}
combinedFormats={combinedFormats}
qualityPresetFormats={qualityPresetFormats}
subtitleLanguages={subtitleLanguages}
/>
</TabsContent>
<TabsContent value="combine">
<CombinedPlaylistDownload
audioOnlyFormats={audioOnlyFormats}
videoOnlyFormats={videoOnlyFormats}
subtitleLanguages={subtitleLanguages}
/>
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,388 @@
import clsx from "clsx";
import { ProxyImage } from "@/components/custom/proxyImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Separator } from "@/components/ui/separator";
import { useDownloaderPageStatesStore } from "@/services/store";
import { formatBitrate, formatDurationString, formatReleaseDate, formatYtStyleCount, isObjEmpty } from "@/utils";
import { Calendar, Clock, DownloadCloud, Eye, Info, ThumbsUp, AlertCircleIcon } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { RawVideoInfo, VideoFormat } from "@/types/video";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { FormatToggleGroup, FormatToggleGroupItem } from "@/components/custom/formatToggleGroup";
import { Layout } from "react-resizable-panels";
interface VideoPreviewProps {
videoMetadata: RawVideoInfo;
}
interface SelectiveVideoDownloadProps {
videoMetadata: RawVideoInfo;
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
combinedFormats: VideoFormat[] | undefined;
qualityPresetFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
interface CombinedVideoDownloadProps {
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
interface VideoDownloaderProps {
videoMetadata: RawVideoInfo;
audioOnlyFormats: VideoFormat[] | undefined;
videoOnlyFormats: VideoFormat[] | undefined;
combinedFormats: VideoFormat[] | undefined;
qualityPresetFormats: VideoFormat[] | undefined;
subtitleLanguages: { code: string; lang: string }[];
}
function VideoPreview({ videoMetadata }: VideoPreviewProps) {
return (
<div className="flex flex-col w-full pr-4">
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
<Info className="w-4 h-4" />
<span>Metadata</span>
</h3>
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
<AspectRatio ratio={16 / 9} className={clsx("w-full rounded-lg overflow-hidden mb-2 border border-border", videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "relative")}>
<ProxyImage src={videoMetadata.thumbnail} alt="thumbnail" className={clsx(videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2")} />
</AspectRatio>
<h2 className="mb-1">{videoMetadata.title ? videoMetadata.title : 'UNTITLED'}</h2>
<p className="text-muted-foreground text-xs mb-2">{videoMetadata.creator || videoMetadata.channel || videoMetadata.uploader || 'unknown'}</p>
<div className="flex items-center mb-2">
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {videoMetadata.duration_string ? formatDurationString(videoMetadata.duration_string) : 'unknown'}</span>
<Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center px-3"><Eye className="w-4 h-4 mr-2"/> {videoMetadata.view_count ? formatYtStyleCount(videoMetadata.view_count) : 'unknown'}</span>
<Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center pl-3"><ThumbsUp className="w-4 h-4 mr-2"/> {videoMetadata.like_count ? formatYtStyleCount(videoMetadata.like_count) : 'unknown'}</span>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-2 mb-2">
<Calendar className="w-4 h-4" />
<span className="">{videoMetadata.upload_date ? formatReleaseDate(videoMetadata.upload_date) : 'unknown'}</span>
</p>
<div className="flex flex-wrap gap-2 text-xs mb-2">
{videoMetadata.resolution && (
<span className="border border-border py-1 px-2 rounded">{videoMetadata.resolution}</span>
)}
{videoMetadata.tbr && (
<span className="border border-border py-1 px-2 rounded">{formatBitrate(videoMetadata.tbr)}</span>
)}
{videoMetadata.fps && (
<span className="border border-border py-1 px-2 rounded">{videoMetadata.fps} fps</span>
)}
{videoMetadata.subtitles && !isObjEmpty(videoMetadata.subtitles) && (
<span className="border border-border py-1 px-2 rounded">SUB</span>
)}
{videoMetadata.dynamic_range && videoMetadata.dynamic_range !== 'SDR' && (
<span className="border border-border py-1 px-2 rounded">{videoMetadata.dynamic_range}</span>
)}
</div>
<div className="flex items-center text-muted-foreground">
<Info className="w-3 h-3 mr-2" />
<span className="text-xs">Extracted from {videoMetadata.extractor ? videoMetadata.extractor.charAt(0).toUpperCase() + videoMetadata.extractor.slice(1) : 'Unknown'}</span>
</div>
<div className="spacer mb-12"></div>
</div>
</div>
);
}
function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectiveVideoDownloadProps) {
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
return (
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
{subtitleLanguages && subtitleLanguages.length > 0 && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
<FormatSelectionGroup
value={selectedDownloadFormat}
onValueChange={(value) => {
setSelectedDownloadFormat(value);
// const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
// if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
// setSelectedSubtitles([]);
// }
resetDownloadConfiguration();
}}
>
<p className="text-xs">Suggested</p>
<div className="">
<FormatSelectionGroupItem
key="best"
value="best"
format={videoMetadata.requested_downloads[0]}
/>
</div>
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
<>
<p className="text-xs">Quality Presets</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{qualityPresetFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
{combinedFormats && combinedFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{combinedFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
<div className="spacer mb-12"></div>
</div>
);
}
function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages }: CombinedVideoDownloadProps) {
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
return (
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && (
<ToggleGroup
type="multiple"
variant="outline"
className="flex flex-col items-start gap-2 mb-2"
value={selectedSubtitles}
onValueChange={(value) => setSelectedSubtitles(value)}
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
<FormatToggleGroup
type="multiple"
variant="outline"
className="mb-2"
value={selectedCombinableAudioFormats}
onValueChange={(value: string[]) => {
setSelectedCombinableAudioFormats(value);
resetDownloadConfiguration();
}}
>
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
<>
<p className="text-xs">Audio</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{audioOnlyFormats.map((format) => (
<FormatToggleGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatToggleGroup>
<FormatSelectionGroup
value={selectedCombinableVideoFormat}
onValueChange={(value) => {
setSelectedCombinableVideoFormat(value);
resetDownloadConfiguration();
}}
>
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
<>
<p className="text-xs">Video</p>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
{videoOnlyFormats.map((format) => (
<FormatSelectionGroupItem
key={format.format_id}
value={format.format_id}
format={format}
/>
))}
</div>
</>
)}
</FormatSelectionGroup>
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
<Alert>
<AlertCircleIcon className="size-4 stroke-primary" />
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
<AlertDescription>
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
</AlertDescription>
</Alert>
)}
<div className="spacer mb-12"></div>
</div>
);
}
export function VideoDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: VideoDownloaderProps) {
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const videoPanelSizes = useDownloaderPageStatesStore((state) => state.videoPanelSizes);
const setVideoPanelSizes = useDownloaderPageStatesStore((state) => state.setVideoPanelSizes);
return (
<div className="flex">
<ResizablePanelGroup
orientation="horizontal"
className="w-full"
onLayoutChanged={(layout: Layout) => {
const firstPanelSize = layout[Object.keys(layout)[0]];
const secondPanelSize = layout[Object.keys(layout)[1]];
setVideoPanelSizes([firstPanelSize, secondPanelSize]);
}}
>
<ResizablePanel
defaultSize={`${videoPanelSizes[0]}%`}
>
<VideoPreview videoMetadata={videoMetadata} />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={`${videoPanelSizes[1]}%`}
>
<div className="flex flex-col w-full pl-4">
<Tabs
className=""
value={activeDownloadModeTab}
onValueChange={(tab) => {
setActiveDownloadModeTab(tab);
resetDownloadConfiguration();
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm flex items-center gap-2">
<DownloadCloud className="w-4 h-4" />
<span>Download Options</span>
</h3>
<TabsList>
<TabsTrigger value="selective">Selective</TabsTrigger>
<TabsTrigger value="combine">Combine</TabsTrigger>
</TabsList>
</div>
<TabsContent value="selective">
<SelectiveVideoDownload
videoMetadata={videoMetadata}
audioOnlyFormats={audioOnlyFormats}
videoOnlyFormats={videoOnlyFormats}
combinedFormats={combinedFormats}
qualityPresetFormats={qualityPresetFormats}
subtitleLanguages={subtitleLanguages}
/>
</TabsContent>
<TabsContent value="combine">
<CombinedVideoDownload
audioOnlyFormats={audioOnlyFormats}
videoOnlyFormats={videoOnlyFormats}
subtitleLanguages={subtitleLanguages}
/>
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,374 @@
import { useEffect } from "react";
import { ProxyImage } from "@/components/custom/proxyImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useLibraryPageStatesStore } from "@/services/store";
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, paginate } from "@/utils";
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import * as fs from "@tauri-apps/plugin-fs";
import { dirname } from "@tauri-apps/api/path";
import { DownloadState } from "@/types/download";
import { useQueryClient } from "@tanstack/react-query";
import { useDeleteDownloadState } from "@/services/mutations";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom";
import { useLogger } from "@/helpers/use-logger";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import PaginationBar from "@/components/custom/paginationBar";
interface CompletedDownloadProps {
state: DownloadState;
}
interface CompletedDownloadsProps {
downloads: DownloadState[];
}
export function CompletedDownload({ state }: CompletedDownloadProps) {
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked);
const queryClient = useQueryClient();
const downloadStateDeleter = useDeleteDownloadState();
const navigate = useNavigate();
const LOG = useLogger();
const openFile = async (filePath: string | null, app: string | null) => {
if (filePath && await fs.exists(filePath)) {
try {
await invoke('open_file_with_app', { filePath: filePath, appName: app }).then(() => {
toast.info(`${app === 'explorer' ? 'Revealing' : 'Opening'} file`, {
description: `${app === 'explorer' ? 'Revealing' : 'Opening'} the file ${app === 'explorer' ? 'in' : 'with'} ${app ? app : 'default app'}.`,
})
});
} catch (e) {
console.error(e);
toast.error(`Failed to ${app === 'explorer' ? 'reveal' : 'open'} file`, {
description: `An error occurred while trying to ${app === 'explorer' ? 'reveal' : 'open'} the file.`,
})
}
} else {
toast.info("File unavailable", {
description: `The file you are trying to ${app === 'explorer' ? 'reveal' : 'open'} does not exist.`,
})
}
}
const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => {
if (delete_file && downloadState.filepath) {
const isMultiplePlaylistItems = downloadState.playlist_id !== null &&
downloadState.playlist_indices !== null &&
downloadState.playlist_indices.includes(',');
if (isMultiplePlaylistItems) {
const dirPath = await dirname(downloadState.filepath);
try {
if (await fs.exists(dirPath)) {
await fs.remove(dirPath, { recursive: true });
} else {
console.error(`Directory not found: "${dirPath}"`);
}
} catch (e) {
console.error(e);
}
} else {
try {
if (await fs.exists(downloadState.filepath)) {
await fs.remove(downloadState.filepath);
} else {
console.error(`File not found: "${downloadState.filepath}"`);
}
} catch (e) {
console.error(e);
}
}
}
downloadStateDeleter.mutate(downloadState.download_id, {
onSuccess: (data) => {
console.log("Download State deleted successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
if (delete_file && downloadState.filepath) {
toast.success("Deleted from downloads", {
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been deleted successfully.`,
});
} else {
toast.success("Removed from downloads", {
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been removed successfully.`,
});
}
},
onError: (error) => {
console.error("Failed to delete download state:", error);
if (delete_file && downloadState.filepath) {
toast.error("Failed to delete download", {
description: `An error occurred while trying to delete the download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
});
} else {
toast.error("Failed to remove download", {
description: `An error occurred while trying to remove the download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
});
}
}
})
}
const handleSearch = async (url: string, isPlaylist: boolean) => {
try {
LOG.info('NEODLP', `Received search request from library for URL: ${url}`);
navigate('/');
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
setRequestedUrl(url);
setAutoSubmitSearch(true);
toast.info(`Initiating ${isPlaylist ? 'Playlist' : 'Video'} Search`, {
description: `Initiating search for the selected ${isPlaylist ? 'playlist' : 'video'}.`,
});
} catch (e) {
console.error(e);
toast.error("Failed to initiate search", {
description: "An error occurred while trying to initiate the search.",
});
}
}
const itemActionStates = downloadActions[state.download_id] || {
isResuming: false,
isPausing: false,
isCanceling: false,
isDeleteFileChecked: false,
};
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMultiplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
const isMultipleAudioFormatSelected = state.format_id ? state.format_id.split('+').length > 2 : false;
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
{isMultiplePlaylistItems ? (
<div className="w-full relative flex items-center justify-center mt-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
</div>
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
</div>
</div>
) : (
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
)}
{isMultiplePlaylistItems ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
</span>
) : (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2 stroke-primary" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
</span>
)}
</div>
<div className="w-full flex flex-col justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="">{isMultiplePlaylistItems ? state.playlist_title : state.title}</h4>
<p className="text-xs text-muted-foreground">{isMultiplePlaylistItems ? state.playlist_channel ?? 'unknown' : state.channel ?? 'unknown'} {state.host ? <><span className="text-primary"></span> {state.host}</> : 'unknown'}</p>
<div className="flex items-center mt-1">
<span className="text-xs text-muted-foreground flex items-center pr-3">
{isMultiplePlaylistItems ? (
<><ListVideo className="w-4 h-4 mr-2"/> {state.playlist_n_entries ?? 'unknown'}</>
) : (
<><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</>
)}
</span>
<Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center px-3">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<FileVideo2 className="w-4 h-4 mr-2"/>
)}
{state.filetype && state.filetype === 'audio' && (
<FileAudio2 className="w-4 h-4 mr-2" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<FileQuestion className="w-4 h-4 mr-2" />
)}
{state.filesize ? formatFileSize(state.filesize) : 'unknown'}
</span>
<Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center pl-3"><AudioLines className="w-4 h-4 mr-2"/>
{state.vbr && state.abr ? (
formatBitrate(state.vbr + state.abr)
) : state.vbr ? (
formatBitrate(state.vbr)
) : state.abr ? (
formatBitrate(state.abr)
) : (
'unknown'
)}
</span>
</div>
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs">
{state.playlist_id && state.playlist_indices && !isMultiplePlaylistItems && (
<span
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer"
title={`${state.playlist_title ?? 'UNKNOWN PLAYLIST'}` + ' by ' + `${state.playlist_channel ?? 'UNKNOWN CHANNEL'}`}
>
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_indices} of {state.playlist_n_entries})
</span>
)}
{state.vcodec && !isMultiplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
)}
{state.acodec && !isMultiplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
)}
{isMultipleAudioFormatSelected && (
<span className="border border-border py-1 px-2 rounded">MULTIAUDIO</span>
)}
{state.dynamic_range && state.dynamic_range !== 'SDR' && !isMultiplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
)}
{state.subtitle_id && (
<span
className="border border-border py-1 px-2 rounded cursor-pointer"
title={`EMBEDED SUBTITLE (${state.subtitle_id})`}
>
ESUB
</span>
)}
{state.sponsorblock_mark && (
<span
className="border border-border py-1 px-2 rounded cursor-pointer"
title={`SPONSORBLOCK MARKED (${state.sponsorblock_mark})`}
>
SPBLOCK(M)
</span>
)}
{state.sponsorblock_remove && (
<span
className="border border-border py-1 px-2 rounded cursor-pointer"
title={`SPONSORBLOCK REMOVED (${state.sponsorblock_remove})`}
>
SPBLOCK(R)
</span>
)}
</div>
</div>
<div className="w-full flex items-center gap-2">
<Button size="sm" onClick={() => openFile(state.filepath, null)}>
<Play className="w-4 h-4" />
Open
</Button>
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
<FolderInput className="w-4 h-4" />
Reveal
</Button>
<Button size="sm" variant="outline" onClick={() => handleSearch(state.url, state.playlist_id ? true : false)}>
<Search className="w-4 h-4" />
Search
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
<Trash2 className="w-4 h-4" />
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from library?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this download from the library? You can also delete the downloaded file by cheking the box below. This action cannot be undone.
</AlertDialogDescription>
<div className="flex items-center space-x-2">
<Checkbox id="delete-file" checked={itemActionStates.isDeleteFileChecked} onCheckedChange={() => {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} />
<Label htmlFor="delete-file">Delete the downloaded file</Label>
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={
() => removeFromDownloads(state, itemActionStates.isDeleteFileChecked).then(() => {
setIsDeleteFileChecked(state.download_id, false);
})
}>Remove</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
}
export function CompletedDownloads({ downloads }: CompletedDownloadsProps) {
const activeCompletedDownloadsPage = useLibraryPageStatesStore(state => state.activeCompletedDownloadsPage);
const setActiveCompletedDownloadsPage = useLibraryPageStatesStore(state => state.setActiveCompletedDownloadsPage);
const navigate = useNavigate();
const paginatedCompletedDownloads = paginate(downloads, activeCompletedDownloadsPage, 5);
// Ensure current page is valid when downloads change
useEffect(() => {
if (downloads.length > 0 && activeCompletedDownloadsPage > paginatedCompletedDownloads.last_page) {
setActiveCompletedDownloadsPage(paginatedCompletedDownloads.last_page);
}
}, [downloads.length, activeCompletedDownloadsPage, paginatedCompletedDownloads.last_page, setActiveCompletedDownloadsPage]);
return (
<div className="w-full flex flex-col gap-2">
{paginatedCompletedDownloads.data.length > 0 ? (
<>
{paginatedCompletedDownloads.data.map((state) => {
return (
<CompletedDownload key={state.download_id} state={state} />
);
})}
{paginatedCompletedDownloads.pages.length > 1 && (
<PaginationBar
paginatedData={paginatedCompletedDownloads}
setPage={setActiveCompletedDownloadsPage}
/>
)}
</>
) : (
<Empty className="mt-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleArrowDown className="stroke-primary" />
</EmptyMedia>
<EmptyTitle>No Completed Downloads</EmptyTitle>
<EmptyDescription>
You have not completed any downloads yet! Complete downloading something to see here :)
</EmptyDescription>
</EmptyHeader>
<Button
variant="link"
className="text-muted-foreground"
size="sm"
onClick={() => navigate("/")}
>
Spin Up a New Download <ArrowUpRightIcon />
</Button>
</Empty>
)}
</div>
);
}

View File

@@ -0,0 +1,302 @@
import { IndeterminateProgress } from "@/components/custom/indeterminateProgress";
import { ProxyImage } from "@/components/custom/proxyImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
import { ArrowUpRightIcon, CircleCheck, File, Info, ListVideo, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
import { DownloadState } from "@/types/download";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { useNavigate } from "react-router-dom";
interface IncompleteDownloadProps {
state: DownloadState;
}
interface IncompleteDownloadsProps {
downloads: DownloadState[];
}
export function IncompleteDownload({ state }: IncompleteDownloadProps) {
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload);
const setIsPausingDownload = useDownloadActionStatesStore(state => state.setIsPausingDownload);
const setIsCancelingDownload = useDownloadActionStatesStore(state => state.setIsCancelingDownload);
const debugMode = useSettingsPageStatesStore(state => state.settings.debug_mode);
const { pauseDownload, resumeDownload, cancelDownload } = useAppContext()
const itemActionStates = downloadActions[state.download_id] || {
isResuming: false,
isPausing: false,
isCanceling: false,
isDeleteFileChecked: false,
};
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMultiplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
{isMultiplePlaylistItems ? (
<div className="w-full relative flex items-center justify-center mt-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
</div>
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
</div>
</div>
) : (
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
)}
{isMultiplePlaylistItems ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
</span>
) : state.ext ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2 stroke-primary" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
</span>
) : (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.download_status === 'starting' ? (
<><Loader2 className="h-4 w-4 mr-2 stroke-primary animate-spin" /> Processing...</>
) : (
<><File className="w-4 h-4 mr-2 stroke-primary" /> Unknown</>
)}
</span>
)}
</div>
<div className="w-full flex flex-col justify-between">
<div className="flex flex-col gap-1">
<h4>{isMultiplePlaylistItems ? state.playlist_title : state.title}</h4>
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
<IndeterminateProgress indeterminate={true} className="w-full" />
)}
{(state.download_status === 'downloading' || state.download_status === 'paused' || state.download_status === 'errored') && state.progress && state.status !== 'finished' && (
<div className="w-full flex items-center gap-2">
{isMultiplePlaylistItems && state.item ? (
<span className="text-sm text-nowrap">({state.item})</span>
) : null}
<span className="text-sm text-nowrap">{state.progress}%</span>
<Progress value={state.progress} />
<span className="text-sm text-nowrap">{
state.downloaded && state.total
? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})`
: null
}</span>
</div>
)}
<div className="text-xs text-muted-foreground">
{state.download_status && state.download_status === 'downloading' && state.status === 'finished' ? (
<span>Processing</span>
) : state.download_status && state.download_status === 'errored' ? (
<span className="text-destructive"><Info className="inline size-3 mb-1 mr-0.5" /> Errored</span>
) : (
<span>{state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)}</span>
)} {
(debugMode && state.download_id) || (state.download_status === 'errored' && state.download_id) ? (
<><span className="text-primary"></span> ID: {state.download_id.toUpperCase()}</>
) : null} {
state.download_status === 'downloading' && state.status !== 'finished' && state.speed && (
<><span className="text-primary"></span> Speed: {formatSpeed(state.speed)}</>
)} {state.download_status === 'downloading' && state.eta && (
<><span className="text-primary"></span> ETA: {formatSecToTimeString(state.eta)}</>
)}
</div>
</div>
<div className="w-full flex items-center gap-2 mt-2">
{state.download_status === 'paused' ? (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsResumingDownload(state.download_id, true);
try {
await resumeDownload(state)
// toast.success("Resumed Download", {
// description: "Download resumed, it will re-start shortly.",
// })
} catch (e) {
console.error(e);
toast.error("Failed to Resume Download", {
description: `An error occurred while trying to resume the download for "${state.title}".`,
})
} finally {
setIsResumingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
>
{itemActionStates.isResuming ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Resuming
</>
) : (
<>
<Play className="w-4 h-4" />
Resume
</>
)}
</Button>
) : state.download_status === 'errored' ? (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsResumingDownload(state.download_id, true);
try {
await resumeDownload(state);
} catch (e) {
console.error(e);
toast.error("Failed to Restart Download", {
description: `An error occurred while trying to restart the download for "${state.title}".`,
})
} finally {
setIsResumingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
>
{itemActionStates.isResuming ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Retrying
</>
) : (
<>
<RotateCw className="w-4 h-4" />
Retry
</>
)}
</Button>
) : (
<Button
size="sm"
className="w-fill"
onClick={async () => {
setIsPausingDownload(state.download_id, true);
try {
await pauseDownload(state)
// toast.success("Paused Download", {
// description: "Download paused successfully.",
// })
} catch (e) {
console.error(e);
toast.error("Failed to Pause Download", {
description: `An error occurred while trying to pause the download for "${state.title}".`,
})
} finally {
setIsPausingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isPausing || itemActionStates.isCanceling || state.download_status !== 'downloading' || (state.download_status === 'downloading' && state.status === 'finished')}
>
{itemActionStates.isPausing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Pausing
</>
) : (
<>
<Pause className="w-4 h-4" />
Pause
</>
)}
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={async () => {
setIsCancelingDownload(state.download_id, true);
try {
await cancelDownload(state)
toast.success("Canceled Download", {
description: `The download for "${state.title}" has been canceled.`,
})
} catch (e) {
console.error(e);
toast.error("Failed to Cancel Download", {
description: `An error occurred while trying to cancel the download for "${state.title}".`,
})
} finally {
setIsCancelingDownload(state.download_id, false);
}
}}
disabled={itemActionStates.isCanceling || itemActionStates.isResuming || itemActionStates.isPausing || state.download_status === 'starting' || (state.download_status === 'downloading' && state.status === 'finished')}
>
{itemActionStates.isCanceling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Canceling
</>
) : (
<>
<X className="w-4 h-4" />
Cancel
</>
)}
</Button>
</div>
</div>
</div>
);
}
export function IncompleteDownloads({ downloads }: IncompleteDownloadsProps) {
const navigate = useNavigate();
return (
<div className="w-full flex flex-col gap-2">
{downloads.length > 0 ? (
downloads.map((state) => {
return (
<IncompleteDownload key={state.download_id} state={state} />
);
})
) : (
<Empty className="mt-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleCheck className="stroke-primary" />
</EmptyMedia>
<EmptyTitle>No Incomplete Downloads</EmptyTitle>
<EmptyDescription>
You have all caught up! Sit back and relax or just spin up a new download to see here :)
</EmptyDescription>
</EmptyHeader>
<Button
variant="link"
className="text-muted-foreground"
size="sm"
onClick={() => navigate("/")}
>
Spin Up a New Download <ArrowUpRightIcon />
</Button>
</Empty>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
import { useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettingsPageStatesStore } from "@/services/store";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { ArrowDownToLine, ArrowRight, EthernetPort, Loader2, Radio, RotateCw } from "lucide-react";
import { useSettings } from "@/helpers/use-settings";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { invoke } from "@tauri-apps/api/core";
import { SlidingButton } from "@/components/custom/slidingButton";
import clsx from "clsx";
import { NumberInput } from "@/components/custom/numberInput";
const websocketPortSchema = z.object({
port: z.coerce.number<number>({
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
? "Websocket Port is required"
: "Websocket Port must be a valid number"
}).int({
message: "Websocket Port must be an integer"
}).min(50000, {
message: "Websocket Port must be at least 50000"
}).max(60000, {
message: "Websocket Port must be at most 60000"
}),
});
function ExtInstallSettings() {
const openLink = async (url: string, app: string | null) => {
try {
await invoke('open_file_with_app', { filePath: url, appName: app }).then(() => {
toast.info("Opening link", {
description: `Opening link with ${app ? app : 'default app'}.`,
})
});
} catch (e) {
console.error(e);
toast.error("Failed to open link", {
description: "An error occurred while trying to open the link.",
})
}
}
return (
<div className="install-neodlp-extension">
<h3 className="font-semibold">NeoDLP Extension</h3>
<p className="text-xs text-muted-foreground mb-4">Integrate NeoDLP with your favourite browser</p>
<div className="flex items-center gap-4 mb-4">
<SlidingButton
slidingContent={
<div className="flex items-center justify-center gap-2 text-primary-foreground">
<ArrowRight className="size-4" />
<span>Get Now</span>
</div>
}
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'chrome')}
>
<span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M0 256C0 209.4 12.5 165.6 34.3 127.1L144.1 318.3C166 357.5 207.9 384 256 384C270.3 384 283.1 381.7 296.8 377.4L220.5 509.6C95.9 492.3 0 385.3 0 256zM365.1 321.6C377.4 302.4 384 279.1 384 256C384 217.8 367.2 183.5 340.7 160H493.4C505.4 189.6 512 222.1 512 256C512 397.4 397.4 511.1 256 512L365.1 321.6zM477.8 128H256C193.1 128 142.3 172.1 130.5 230.7L54.2 98.5C101 38.5 174 0 256 0C350.8 0 433.5 51.5 477.8 128V128zM168 256C168 207.4 207.4 168 256 168C304.6 168 344 207.4 344 256C344 304.6 304.6 344 256 344C207.4 344 168 304.6 168 256z"/>
</svg>
Get Chrome Extension
</span>
<span className="text-xs">from Chrome Web Store</span>
</SlidingButton>
<SlidingButton
slidingContent={
<div className="flex items-center justify-center gap-2 text-primary-foreground">
<ArrowRight className="size-4" />
<span>Get Now</span>
</div>
}
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'firefox')}
>
<span className="font-semibold flex items-center gap-2">
<svg className="size-4 fill-primary-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M130.2 127.5C130.4 127.6 130.3 127.6 130.2 127.5V127.5zM481.6 172.9C471 147.4 449.6 119.9 432.7 111.2C446.4 138.1 454.4 165 457.4 185.2C457.4 185.3 457.4 185.4 457.5 185.6C429.9 116.8 383.1 89.1 344.9 28.7C329.9 5.1 334 3.5 331.8 4.1L331.7 4.2C285 30.1 256.4 82.5 249.1 126.9C232.5 127.8 216.2 131.9 201.2 139C199.8 139.6 198.7 140.7 198.1 142C197.4 143.4 197.2 144.9 197.5 146.3C197.7 147.2 198.1 148 198.6 148.6C199.1 149.3 199.8 149.9 200.5 150.3C201.3 150.7 202.1 151 203 151.1C203.8 151.1 204.7 151 205.5 150.8L206 150.6C221.5 143.3 238.4 139.4 255.5 139.2C318.4 138.7 352.7 183.3 363.2 201.5C350.2 192.4 326.8 183.3 304.3 187.2C392.1 231.1 368.5 381.8 247 376.4C187.5 373.8 149.9 325.5 146.4 285.6C146.4 285.6 157.7 243.7 227 243.7C234.5 243.7 256 222.8 256.4 216.7C256.3 214.7 213.8 197.8 197.3 181.5C188.4 172.8 184.2 168.6 180.5 165.5C178.5 163.8 176.4 162.2 174.2 160.7C168.6 141.2 168.4 120.6 173.5 101.1C148.5 112.5 129 130.5 114.8 146.4H114.7C105 134.2 105.7 93.8 106.3 85.3C106.1 84.8 99 89 98.1 89.7C89.5 95.7 81.6 102.6 74.3 110.1C58 126.7 30.1 160.2 18.8 211.3C14.2 231.7 12 255.7 12 263.6C12 398.3 121.2 507.5 255.9 507.5C376.6 507.5 478.9 420.3 496.4 304.9C507.9 228.2 481.6 173.8 481.6 172.9z"/>
</svg>
Get Firefox Extension
</span>
<span className="text-xs">from Mozilla Addons Store</span>
</SlidingButton>
</div>
<div className="flex gap-2 mb-4">
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'msedge')}>Edge</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'opera')}>Opera</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'brave')}>Brave</Button>
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'vivaldi')}>Vivaldi</Button>
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'zen')}>Zen</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed before clicking the link</p>
</div>
);
}
function ExtPortSettings() {
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port);
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
const setIsChangingWebSocketPort = useSettingsPageStatesStore(state => state.setIsChangingWebSocketPort);
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
const { saveSettingsKey } = useSettings();
interface Config {
port: number;
}
const websocketPortForm = useForm<z.infer<typeof websocketPortSchema>>({
resolver: zodResolver(websocketPortSchema),
defaultValues: {
port: websocketPort,
},
mode: "onChange",
});
const watchedPort = websocketPortForm.watch("port");
const { errors: websocketPortFormErrors } = websocketPortForm.formState;
async function handleWebsocketPortSubmit(values: z.infer<typeof websocketPortSchema>) {
setIsChangingWebSocketPort(true);
try {
// const port = parseInt(values.port, 10);
const updatedConfig: Config = await invoke("update_config", {
newConfig: {
port: values.port,
}
});
saveSettingsKey('websocket_port', updatedConfig.port);
toast.success("Websocket port updated", {
description: `Websocket port changed to ${values.port}`,
});
} catch (error) {
console.error("Error changing websocket port:", error);
toast.error("Failed to change websocket port", {
description: "An error occurred while trying to change the websocket port. Please try again.",
});
} finally {
setIsChangingWebSocketPort(false);
}
}
useEffect(() => {
if (formResetTrigger > 0) {
websocketPortForm.reset();
acknowledgeFormReset();
}
}, [formResetTrigger]);
return (
<div className="websocket-port">
<h3 className="font-semibold">Websocket Port</h3>
<p className="text-xs text-muted-foreground mb-3">Change extension websocket server port</p>
<div className="flex items-center gap-4">
<Form {...websocketPortForm}>
<form onSubmit={websocketPortForm.handleSubmit(handleWebsocketPortSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField
control={websocketPortForm.control}
name="port"
disabled={isChangingWebSocketPort}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<NumberInput
className="w-full"
placeholder="Enter port number"
min={0}
{...field}
/>
</FormControl>
<Label htmlFor="port" className="text-xs text-muted-foreground">(Current: {websocketPort}) (Default: 53511, Range: 50000-60000)</Label>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!watchedPort || Number(watchedPort) === websocketPort || Object.keys(websocketPortFormErrors).length > 0 || isChangingWebSocketPort || isRestartingWebSocketServer}
>
{isChangingWebSocketPort ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Changing
</>
) : (
'Change'
)}
</Button>
</form>
</Form>
</div>
</div>
);
}
export function ExtensionSettings() {
const activeSubExtTab = useSettingsPageStatesStore(state => state.activeSubExtTab);
const setActiveSubExtTab = useSettingsPageStatesStore(state => state.setActiveSubExtTab);
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
const setIsRestartingWebSocketServer = useSettingsPageStatesStore(state => state.setIsRestartingWebSocketServer);
const tabsList = [
{ key: "install", label: "Install", icon: ArrowDownToLine, component: <ExtInstallSettings /> },
{ key: "port", label: "Port", icon: EthernetPort, component: <ExtPortSettings /> },
];
return (
<>
<Card className="p-4 space-y-4 my-4">
<div className="w-full flex gap-4 items-center justify-between">
<div className="flex gap-4 items-center">
<div className="imgwrapper w-10 h-10 flex items-center justify-center bg-linear-65 from-[#FF43D0] to-[#4444FF] customscheme:from-chart-1 customscheme:to-chart-5 rounded-md overflow-hidden border border-border">
<Radio className="size-5 text-white" />
</div>
<div className="flex flex-col">
<h3 className="">Extension Websocket Server</h3>
<div className="text-xs flex items-center">
{isChangingWebSocketPort || isRestartingWebSocketServer ? (
<><div className="h-1.5 w-1.5 rounded-full bg-amber-600 dark:bg-amber-500 mr-1.5 mt-0.5" /><span className="text-amber-600 dark:text-amber-500">Restarting...</span></>
) : (
<><div className="h-1.5 w-1.5 rounded-full bg-emerald-600 dark:bg-emerald-500 mr-1.5 mt-0.5" /><span className="text-emerald-600 dark:text-emerald-500">Running</span></>
)}
</div>
</div>
</div>
<div className="flex gap-4 items-center">
<Button
onClick={async () => {
setIsRestartingWebSocketServer(true);
try {
await invoke("restart_websocket_server");
toast.success("Websocket server restarted", {
description: "Websocket server restarted successfully.",
});
} catch (error) {
console.error("Error restarting websocket server:", error);
toast.error("Failed to restart websocket server", {
description: "An error occurred while trying to restart the websocket server. Please try again.",
});
} finally {
setIsRestartingWebSocketServer(false);
}
}}
disabled={isRestartingWebSocketServer || isChangingWebSocketPort}
>
{isRestartingWebSocketServer ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Restarting
</>
) : (
<>
<RotateCw className="h-4 w-4" />
Restart
</>
)}
</Button>
</div>
</div>
</Card>
<Tabs
className="w-full flex flex-row items-start gap-4 mt-7"
orientation="vertical"
value={activeSubExtTab}
onValueChange={setActiveSubExtTab}
>
<TabsList className="shrink-0 grid grid-cols-1 gap-1 p-0 bg-background min-w-45">
{tabsList.map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
>
<tab.icon className="size-4" /> {tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-full flex flex-col w-full border-l border-border pl-4">
{tabsList.map((tab) => (
<TabsContent key={tab.key} value={tab.key} className={clsx("flex flex-col gap-4 min-h-37.5", tab.key === "install" ? "max-w-[90%]" : "max-w-[70%]")}>
{tab.component}
</TabsContent>
))}
</div>
</Tabs>
</>
);
}

View File

@@ -1,9 +1,10 @@
import { config } from "@/config"; import { config } from "@/config";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
import { CircleArrowUp, Download, Settings, SquarePlay, } from "lucide-react"; import { CircleArrowUp } from "lucide-react";
import { isActive as isActiveSidebarItem } from "@/utils"; import { isActive as isActiveSidebarItem } from "@/utils";
import { RoutesObj } from "@/types/route"; import { RoutesObj } from "@/types/route";
import { AllRoutes } from "@/routes";
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -14,212 +15,226 @@ import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import useAppUpdater from "@/helpers/use-app-updater"; import useAppUpdater from "@/helpers/use-app-updater";
import { platform } from "@tauri-apps/plugin-os";
import * as fs from "@tauri-apps/plugin-fs";
export function AppSidebar() { export function AppSidebar() {
const downloadStates = useDownloadStatesStore(state => state.downloadStates); const downloadStates = useDownloadStatesStore(state => state.downloadStates);
const ongoingDownloads = downloadStates.filter(state => const ongoingDownloads = downloadStates.filter(state =>
['starting', 'downloading', 'queued'].includes(state.download_status) ['starting', 'downloading', 'queued'].includes(state.download_status)
); );
const appVersion = useSettingsPageStatesStore(state => state.appVersion); const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const isFetchingAppVersion = useSettingsPageStatesStore(state => state.isFetchingAppVersion); const isFetchingAppVersion = useSettingsPageStatesStore(state => state.isFetchingAppVersion);
const appUpdate = useSettingsPageStatesStore(state => state.appUpdate); const appUpdate = useSettingsPageStatesStore(state => state.appUpdate);
const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp); const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp);
const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress); const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress);
const location = useLocation(); const currentPlatform = platform();
const { open } = useSidebar(); const location = useLocation();
const { downloadAndInstallAppUpdate } = useAppUpdater(); const { open } = useSidebar();
const [showBadge, setShowBadge] = useState(false); const { downloadAndInstallAppUpdate } = useAppUpdater();
const [showUpdateCard, setShowUpdateCard] = useState(false); const [showBadge, setShowBadge] = useState(false);
const [showUpdateCard, setShowUpdateCard] = useState(false);
const [isNativeLinuxApp, setIsNativeLinuxApp] = useState(false);
const topItems: Array<RoutesObj> = [ const topItems: Array<RoutesObj> = [
{ AllRoutes[0], // Downloader
title: "Downloader", AllRoutes[1], // Library
url: "/", ];
icon: Download,
},
{
title: "Library",
url: "/library",
icon: SquarePlay,
}
];
const bottomItems: Array<RoutesObj> = [ const bottomItems: Array<RoutesObj> = [
{ AllRoutes[2], // Settings
title: "Settings", ];
url: "/settings",
icon: Settings,
}
];
useEffect(() => { useEffect(() => {
let timeout: NodeJS.Timeout; let timeout: NodeJS.Timeout;
if (open) { if (open) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
setShowBadge(true); setShowBadge(true);
setShowUpdateCard(true); setShowUpdateCard(true);
}, 300); }, 300);
} else { } else {
setShowBadge(false); setShowBadge(false);
setShowUpdateCard(false); setShowUpdateCard(false);
} }
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [open]); }, [open]);
return ( useEffect(() => {
<Sidebar collapsible="icon"> (async () => {
<SidebarHeader> if (currentPlatform === 'linux') {
<SidebarMenu> const neoDlpExists = await fs.exists('/usr/bin/neodlp');
<SidebarMenuItem> setIsNativeLinuxApp(neoDlpExists);
<SidebarMenuButton size="lg" asChild> }
<a href="#"> })();
<div className="flex aspect-square size-8 items-center justify-center rounded-lg"> }, [currentPlatform]);
<NeoDlpLogo className="size-full rounded-lg border border-border" />
</div> return (
<div className="grid flex-1 text-left text-sm leading-tight"> <Sidebar collapsible="icon">
<span className="truncate font-semibold">Neo Downloader Plus</span> <SidebarHeader>
<span className="truncate text-xs">{isFetchingAppVersion ? 'Loading...' : `v${appVersion}`}</span> <SidebarMenu>
</div> <SidebarMenuItem>
</a> <SidebarMenuButton size="lg" asChild>
</SidebarMenuButton> <a href="#">
</SidebarMenuItem> <div className="flex aspect-square size-8 items-center justify-center rounded-lg">
</SidebarMenu> <NeoDlpLogo className="size-full rounded-md border border-border [--logo-stop-color-1:#4444FF] [--logo-stop-color-2:#FF43D0] customscheme:[--logo-stop-color-1:var(--color-chart-5)] customscheme:[--logo-stop-color-2:var(--color-chart-1)]" />
</SidebarHeader> </div>
{/* <SidebarSeparator /> */} <div className="grid flex-1 text-left text-sm leading-tight">
<SidebarContent> <span className="truncate font-semibold">Neo Downloader Plus</span>
<SidebarGroup> <span className="truncate text-xs">{isFetchingAppVersion ? 'Loading...' : `v${appVersion}`}</span>
{/* <SidebarGroupLabel>Tools</SidebarGroupLabel> */} </div>
<SidebarGroupContent> </a>
<SidebarMenu>
{topItems.map((item) => (
<SidebarMenuItem key={item.title}>
{!open ? (
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
className="relative"
asChild
>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
<Badge className="absolute right-2 inset-y-auto rounded-full font-bold bg-foreground/80">{ongoingDownloads.length}</Badge>
)}
</Link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger> </SidebarMenuItem>
<TooltipContent side="right"> </SidebarMenu>
<p>{item.title}</p> </SidebarHeader>
</TooltipContent> {/* <SidebarSeparator /> */}
<SidebarContent>
<SidebarGroup>
{/* <SidebarGroupLabel>Tools</SidebarGroupLabel> */}
<SidebarGroupContent>
<SidebarMenu>
{topItems.map((item) => (
<SidebarMenuItem key={item.title}>
{!open ? (
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
className="relative"
asChild
>
<Link to={item.url}>
<item.icon className="stroke-primary" />
<span>{item.title}</span>
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
<Badge className="absolute right-2 inset-y-auto rounded-full font-bold bg-foreground/80">{ongoingDownloads.length}</Badge>
)}
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
) : (
<SidebarMenuButton
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
className="relative"
asChild
>
<Link to={item.url}>
<item.icon className="stroke-primary" />
<span>{item.title}</span>
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
<Badge className="absolute right-2 inset-y-auto h-5 min-w-5 rounded-full px-1 font-mono tabular-nums flex items-center justify-center">
<span className="mt-0.5">{ongoingDownloads.length}</span>
</Badge>
)}
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
{appUpdate && !open && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost">
<CircleArrowUp className="size-4 stroke-primary" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Update Available <br></br>(Expand sidebar to view update)</p>
</TooltipContent>
</Tooltip> </Tooltip>
) : ( )}
<SidebarMenuButton {appUpdate && open && showUpdateCard && (
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)} <Card className="gap-4 py-0">
className="relative" <CardHeader className="p-4 pb-0">
asChild <CardTitle className="text-sm">Update Available (v{appUpdate?.version || '0.0.0'})</CardTitle>
> <CardDescription>
<Link to={item.url}> A newer version of {config.appName} is available. Please update to the latest version for the best experience.
<item.icon /> </CardDescription>
<span>{item.title}</span> <a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/${config.appRepo}/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank"> Read Changelog</a>
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && ( </CardHeader>
<Badge className="absolute right-2 inset-y-auto h-5 min-w-5 rounded-full px-1 font-mono tabular-nums">{ongoingDownloads.length}</Badge> <CardContent className="grid gap-2.5 p-4">
{isNativeLinuxApp ? (
<Button
className="w-full"
size="sm"
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
asChild
>
<a href={`https://github.com/${config.appRepo}/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank">Download Now</a>
</Button>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full"
size="sm"
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
onClick={() => downloadAndInstallAppUpdate(appUpdate)}
>
Update Now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
<CircleArrowUp className="size-7 stroke-muted-foreground" />
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
<AlertDialogDescription className="text-center text-xs mb-2">Updating {config.appName} to v{appUpdate?.version || '0.0.0'}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
<Progress value={appUpdateDownloadProgress} className="w-full" />
<AlertDialogDescription className="text-center">Downloading update... {appUpdateDownloadProgress}%</AlertDialogDescription>
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
)} )}
</Link> </CardContent>
</SidebarMenuButton> </Card>
)} )}
</SidebarMenuItem> <SidebarMenu>
))} {bottomItems.map((item) => (
</SidebarMenu> <SidebarMenuItem key={item.title}>
</SidebarGroupContent> {!open ? (
</SidebarGroup> <Tooltip>
</SidebarContent> <TooltipTrigger asChild>
<SidebarFooter> <SidebarMenuButton
{appUpdate && !open && ( isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
<Tooltip> asChild
<TooltipTrigger asChild> >
<Button variant="ghost"> <Link to={item.url}>
<CircleArrowUp className="size-4" /> <item.icon className="stroke-primary" />
</Button> <span>{item.title}</span>
</TooltipTrigger> </Link>
<TooltipContent side="right"> </SidebarMenuButton>
<p>Update Available <br></br>(Expand sidebar to view update)</p> </TooltipTrigger>
</TooltipContent> <TooltipContent side="right">
</Tooltip> <p>{item.title}</p>
)} </TooltipContent>
{appUpdate && open && showUpdateCard && ( </Tooltip>
<Card className="gap-4 py-0"> ) : (
<CardHeader className="p-4 pb-0"> <SidebarMenuButton
<CardTitle className="text-sm">Update Available (v{appUpdate?.version || '0.0.0'})</CardTitle> isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
<CardDescription> asChild
A newer version of {config.appName} is available. Please update to the latest version for the best experience. >
</CardDescription> <Link to={item.url}>
<a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/neosubhamoy/neodlp/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank"> Read Changelog</a> <item.icon className="stroke-primary" />
</CardHeader> <span>{item.title}</span>
<CardContent className="grid gap-2.5 p-4"> </Link>
<AlertDialog> </SidebarMenuButton>
<AlertDialogTrigger asChild> )}
<Button </SidebarMenuItem>
className="w-full" ))}
size="sm" </SidebarMenu>
disabled={ongoingDownloads.length > 0 || isUpdatingApp} </SidebarFooter>
onClick={() => downloadAndInstallAppUpdate(appUpdate)} </Sidebar>
> );
Update Now
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
<CircleArrowUp className="size-7 stroke-muted-foreground" />
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
<AlertDialogDescription className="text-center text-xs mb-2">Updating {config.appName} to v{appUpdate?.version || '0.0.0'}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
<Progress value={appUpdateDownloadProgress} className="w-full" />
<AlertDialogDescription className="text-center">Downloading update... {appUpdateDownloadProgress}%</AlertDialogDescription>
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
)}
<SidebarMenu>
{bottomItems.map((item) => (
<SidebarMenuItem key={item.title}>
{!open ? (
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
asChild
>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
) : (
<SidebarMenuButton
isActive={isActiveSidebarItem(item.url, location.pathname, item.starts_with ? item.starts_with : false)}
asChild
>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
} }

View File

@@ -0,0 +1,58 @@
import { useState } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { MaximizeIcon } from "@/components/icons/maximize";
import { MinimizeIcon } from "@/components/icons/minimize";
import { CloseIcon } from "@/components/icons/close";
import { UnmaximizeIcon } from "@/components/icons/unmaximize";
export default function TitleBar() {
const [maximized, setMaximized] = useState<boolean>(false);
const appWindow = getCurrentWebviewWindow();
return (
<div className="titlebar flex items-center justify-between border-b bg-background">
<div className="flex items-center justify-center grow px-4 py-2.5" data-tauri-drag-region>
<h1 className="text-sm text-primary font-semibold">NeoDLP</h1>
</div>
<div className="controls flex items-center justify-center">
<button
className="px-4 py-3 hover:bg-muted"
id="titlebar-minimize"
title="Minimize"
onClick={() => appWindow.minimize()}
>
<MinimizeIcon />
</button>
<button
className="px-4 py-3 hover:bg-muted"
id="titlebar-maximize"
title={maximized ? "Unmaximize" : "Maximize"}
onClick={async () => {
const isMaximized = await appWindow.isMaximized();
if (isMaximized) {
await appWindow.unmaximize();
setMaximized(false);
} else {
await appWindow.maximize();
setMaximized(true);
}
}}
>
{maximized ? (
<UnmaximizeIcon />
) : (
<MaximizeIcon />
)}
</button>
<button
className="px-4 py-3 hover:bg-destructive"
id="titlebar-close"
title="Close"
onClick={() => appWindow.hide()}
>
<CloseIcon />
</button>
</div>
</div>
);
}

View File

@@ -1,64 +0,0 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,146 +1,128 @@
"use client"
import * as React from "react" import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
function AlertDialog({ const AlertDialog = AlertDialogPrimitive.Root
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ const AlertDialogPortal = AlertDialogPrimitive.Portal
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({ const AlertDialogOverlay = React.forwardRef<
className, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
...props React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { >(({ className, ...props }, ref) => (
return ( <AlertDialogPrimitive.Overlay
<AlertDialogPrimitive.Overlay className={cn(
data-slot="alert-dialog-overlay" "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:rounded-lg",
className className
)} )}
{...props} {...props}
/> />
) </AlertDialogPortal>
} ))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
function AlertDialogContent({ const AlertDialogHeader = ({
className, className,
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<AlertDialogPortal> className={cn(
<AlertDialogOverlay /> "flex flex-col space-y-2 text-center sm:text-left",
<AlertDialogPrimitive.Content className
data-slot="alert-dialog-content" )}
className={cn( {...props}
"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", />
className )
)} AlertDialogHeader.displayName = "AlertDialogHeader"
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({ const AlertDialogFooter = ({
className, className,
...props ...props
}: React.ComponentProps<"div">) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<div className={cn(
data-slot="alert-dialog-header" "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className
{...props} )}
/> {...props}
) />
} )
AlertDialogFooter.displayName = "AlertDialogFooter"
function AlertDialogFooter({ const AlertDialogTitle = React.forwardRef<
className, React.ElementRef<typeof AlertDialogPrimitive.Title>,
...props React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
}: React.ComponentProps<"div">) { >(({ className, ...props }, ref) => (
return ( <AlertDialogPrimitive.Title
<div ref={ref}
data-slot="alert-dialog-footer" className={cn("text-lg font-semibold", className)}
className={cn( {...props}
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", />
className ))
)} AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
{...props}
/>
)
}
function AlertDialogTitle({ const AlertDialogDescription = React.forwardRef<
className, React.ElementRef<typeof AlertDialogPrimitive.Description>,
...props React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { >(({ className, ...props }, ref) => (
return ( <AlertDialogPrimitive.Description
<AlertDialogPrimitive.Title ref={ref}
data-slot="alert-dialog-title" className={cn("text-sm text-muted-foreground", className)}
className={cn("text-lg font-semibold", className)} {...props}
{...props} />
/> ))
) AlertDialogDescription.displayName =
} AlertDialogPrimitive.Description.displayName
function AlertDialogDescription({ const AlertDialogAction = React.forwardRef<
className, React.ElementRef<typeof AlertDialogPrimitive.Action>,
...props React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { >(({ className, ...props }, ref) => (
return ( <AlertDialogPrimitive.Action
<AlertDialogPrimitive.Description ref={ref}
data-slot="alert-dialog-description" className={cn(buttonVariants(), className)}
className={cn("text-muted-foreground text-sm", className)} {...props}
{...props} />
/> ))
) AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
}
function AlertDialogAction({ const AlertDialogCancel = React.forwardRef<
className, React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
...props React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { >(({ className, ...props }, ref) => (
return ( <AlertDialogPrimitive.Cancel
<AlertDialogPrimitive.Action ref={ref}
className={cn(buttonVariants(), className)} className={cn(
{...props} buttonVariants({ variant: "outline" }),
/> "mt-2 sm:mt-0",
) className
} )}
{...props}
function AlertDialogCancel({ />
className, ))
...props AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export { export {
AlertDialog, AlertDialog,

View File

@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-card text-card-foreground", default: "bg-background text-foreground",
destructive: destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -19,48 +19,41 @@ const alertVariants = cva(
} }
) )
function Alert({ const Alert = React.forwardRef<
className, HTMLDivElement,
variant, React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
...props >(({ className, variant, ...props }, ref) => (
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { <div
return ( ref={ref}
<div role="alert"
data-slot="alert" className={cn(alertVariants({ variant }), className)}
role="alert" {...props}
className={cn(alertVariants({ variant }), className)} />
{...props} ))
/> Alert.displayName = "Alert"
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { const AlertTitle = React.forwardRef<
return ( HTMLParagraphElement,
<div React.HTMLAttributes<HTMLHeadingElement>
data-slot="alert-title" >(({ className, ...props }, ref) => (
className={cn( <h5
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", ref={ref}
className className={cn("mb-1 font-medium leading-none tracking-tight", className)}
)} {...props}
{...props} />
/> ))
) AlertTitle.displayName = "AlertTitle"
}
function AlertDescription({ const AlertDescription = React.forwardRef<
className, HTMLParagraphElement,
...props React.HTMLAttributes<HTMLParagraphElement>
}: React.ComponentProps<"div">) { >(({ className, ...props }, ref) => (
return ( <div
<div ref={ref}
data-slot="alert-description" className={cn("text-sm [&_p]:leading-relaxed", className)}
className={cn( {...props}
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", />
className ))
)} AlertDescription.displayName = "AlertDescription"
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,9 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({ const AspectRatio = AspectRatioPrimitive.Root
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio } export { AspectRatio }

View File

@@ -1,53 +1,50 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar" import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Avatar({ const Avatar = React.forwardRef<
className, React.ElementRef<typeof AvatarPrimitive.Root>,
...props React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { >(({ className, ...props }, ref) => (
return ( <AvatarPrimitive.Root
<AvatarPrimitive.Root ref={ref}
data-slot="avatar" className={cn(
className={cn( "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
"relative flex size-8 shrink-0 overflow-hidden rounded-full", className
className )}
)} {...props}
{...props} />
/> ))
) Avatar.displayName = AvatarPrimitive.Root.displayName
}
function AvatarImage({ const AvatarImage = React.forwardRef<
className, React.ElementRef<typeof AvatarPrimitive.Image>,
...props React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
}: React.ComponentProps<typeof AvatarPrimitive.Image>) { >(({ className, ...props }, ref) => (
return ( <AvatarPrimitive.Image
<AvatarPrimitive.Image ref={ref}
data-slot="avatar-image" className={cn("aspect-square h-full w-full", className)}
className={cn("aspect-square size-full", className)} {...props}
{...props} />
/> ))
) AvatarImage.displayName = AvatarPrimitive.Image.displayName
}
function AvatarFallback({ const AvatarFallback = React.forwardRef<
className, React.ElementRef<typeof AvatarPrimitive.Fallback>,
...props React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { >(({ className, ...props }, ref) => (
return ( <AvatarPrimitive.Fallback
<AvatarPrimitive.Fallback ref={ref}
data-slot="avatar-fallback" className={cn(
className={cn( "flex h-full w-full items-center justify-center rounded-full bg-muted",
"bg-muted flex size-full items-center justify-center rounded-full", className
className )}
)} {...props}
{...props} />
/> ))
) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
}
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,22 +1,20 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: outline: "text-foreground",
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -25,21 +23,13 @@ const badgeVariants = cva(
} }
) )
function Badge({ export interface BadgeProps
className, extends React.HTMLAttributes<HTMLDivElement>,
variant, VariantProps<typeof badgeVariants> {}
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<Comp <div className={cn(badgeVariants({ variant }), className)} {...props} />
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
) )
} }

View File

@@ -1,109 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,31 +1,32 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot as SlotPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: ghost: "hover:bg-accent hover:text-accent-foreground",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-8",
icon: "size-9", icon: "h-9 w-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -35,25 +36,24 @@ const buttonVariants = cva(
} }
) )
function Button({ export interface ButtonProps
className, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
variant, VariantProps<typeof buttonVariants> {
size, asChild?: boolean
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -1,208 +0,0 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -2,91 +2,75 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) { const Card = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card" >(({ className, ...props }, ref) => (
className={cn( <div
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", ref={ref}
className className={cn(
)} "rounded-xl border bg-card text-card-foreground shadow",
{...props} className
/> )}
) {...props}
} />
))
Card.displayName = "Card"
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { const CardHeader = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card-header" >(({ className, ...props }, ref) => (
className={cn( <div
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", ref={ref}
className className={cn("flex flex-col space-y-1.5 p-6", className)}
)} {...props}
{...props} />
/> ))
) CardHeader.displayName = "CardHeader"
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { const CardTitle = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card-title" >(({ className, ...props }, ref) => (
className={cn("leading-none font-semibold", className)} <div
{...props} ref={ref}
/> className={cn("font-semibold leading-none tracking-tight", className)}
) {...props}
} />
))
CardTitle.displayName = "CardTitle"
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { const CardDescription = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card-description" >(({ className, ...props }, ref) => (
className={cn("text-muted-foreground text-sm", className)} <div
{...props} ref={ref}
/> className={cn("text-sm text-muted-foreground", className)}
) {...props}
} />
))
CardDescription.displayName = "CardDescription"
function CardAction({ className, ...props }: React.ComponentProps<"div">) { const CardContent = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card-action" >(({ className, ...props }, ref) => (
className={cn( <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", ))
className CardContent.displayName = "CardContent"
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) { const CardFooter = React.forwardRef<
return ( HTMLDivElement,
<div React.HTMLAttributes<HTMLDivElement>
data-slot="card-content" >(({ className, ...props }, ref) => (
className={cn("px-6", className)} <div
{...props} ref={ref}
/> className={cn("flex items-center p-6 pt-0", className)}
) {...props}
} />
))
CardFooter.displayName = "CardFooter"
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,241 +0,0 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -1,382 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import type { Props as LegendProps } from "recharts/types/component/Legend"
import { TooltipContentProps } from "recharts/types/component/Tooltip"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
export type CustomTooltipProps = TooltipContentProps<ValueType, NameType> & {
className?: string
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (
label: TooltipContentProps<number, string>["label"],
payload: TooltipContentProps<number, string>["payload"]
) => React.ReactNode
formatter?: (
value: number | string,
name: string,
item: Payload<number | string, string>,
index: number,
payload: ReadonlyArray<Payload<number | string, string>>
) => React.ReactNode
labelClassName?: string
color?: string
}
export type ChartLegendContentProps = {
className?: string
hideIcon?: boolean
verticalAlign?: LegendProps["verticalAlign"]
payload?: LegendPayload[]
nameKey?: string
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
label,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
labelFormatter,
formatter,
labelClassName,
color,
nameKey,
labelKey,
}: CustomTooltipProps) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = (() => {
const v =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label ?? label
: itemConfig?.label
return typeof v === "string" || typeof v === "number" ? v : undefined
})()
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
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",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: ChartLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -1,7 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -21,7 +19,7 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" className="grid place-content-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
@@ -30,3 +28,4 @@ function Checkbox({
} }
export { Checkbox } export { Checkbox }

View File

@@ -1,31 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -1,184 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -1,252 +0,0 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -1,141 +1,122 @@
"use client"
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dialog({ const Dialog = DialogPrimitive.Root
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ const DialogTrigger = DialogPrimitive.Trigger
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ const DialogPortal = DialogPrimitive.Portal
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ const DialogClose = DialogPrimitive.Close
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ const DialogOverlay = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Overlay>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { >(({ className, ...props }, ref) => (
return ( <DialogPrimitive.Overlay
<DialogPrimitive.Overlay ref={ref}
data-slot="dialog-overlay" className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:rounded-lg",
className className
)} )}
{...props} {...props}
/> >
) {children}
} <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogContent({ const DialogHeader = ({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<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",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<DialogPrimitive.Title className={cn(
data-slot="dialog-title" "flex flex-col space-y-1.5 text-center sm:text-left",
className={cn("text-lg leading-none font-semibold", className)} className
{...props} )}
/> {...props}
) />
} )
DialogHeader.displayName = "DialogHeader"
function DialogDescription({ const DialogFooter = ({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<DialogPrimitive.Description className={cn(
data-slot="dialog-description" "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className={cn("text-muted-foreground text-sm", className)} className
{...props} )}
/> {...props}
) />
} )
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
Dialog, Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogFooter,
DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogDescription,
} }

View File

@@ -1,133 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background 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",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

104
src/components/ui/empty.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -1,11 +1,10 @@
import * as React from "react" import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label" import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
import { Slot } from "@radix-ui/react-slot"
import { import {
Controller, Controller,
FormProvider, FormProvider,
useFormContext, useFormContext,
useFormState,
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
@@ -18,18 +17,16 @@ const Form = FormProvider
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = { > = {
name: TName name: TName
} }
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
{} as FormFieldContextValue
)
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
@@ -43,14 +40,18 @@ const FormField = <
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext() const { getFieldState, formState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>")
} }
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext const { id } = itemContext
return { return {
@@ -67,47 +68,48 @@ type FormItemContextValue = {
id: string id: string
} }
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue | null>(null)
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) { const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId() const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div ref={ref} className={cn("space-y-2", className)} {...props} />
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider> </FormItemContext.Provider>
) )
} })
FormItem.displayName = "FormItem"
function FormLabel({ const FormLabel = React.forwardRef<
className, React.ElementRef<typeof LabelPrimitive.Root>,
...props React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
}: React.ComponentProps<typeof LabelPrimitive.Root>) { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField()
return ( return (
<Label <Label
data-slot="form-label" ref={ref}
data-error={!!error} className={cn(error && "text-destructive", className)}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) )
} })
FormLabel.displayName = "FormLabel"
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { const FormControl = React.forwardRef<
React.ElementRef<typeof SlotPrimitive.Slot>,
React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return ( return (
<Slot <SlotPrimitive.Slot
data-slot="form-control" ref={ref}
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
@@ -118,24 +120,32 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
{...props} {...props}
/> />
) )
} })
FormControl.displayName = "FormControl"
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField()
return ( return (
<p <p
data-slot="form-description" ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)} className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} {...props}
/> />
) )
} })
FormDescription.displayName = "FormDescription"
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? "") : children
if (!body) { if (!body) {
return null return null
@@ -143,15 +153,16 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
data-slot="form-message" ref={ref}
id={formMessageId} id={formMessageId}
className={cn("text-destructive text-sm", className)} className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) )
} })
FormMessage.displayName = "FormMessage"
export { export {
useFormField, useFormField,

View File

@@ -1,42 +0,0 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -1,77 +0,0 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -2,20 +2,21 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
return ( ({ className, type, ...props }, ref) => {
<input return (
type={type} <input
data-slot="input" type={type}
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", className
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", )}
className ref={ref}
)} {...props}
{...props} />
/> )
) }
} )
Input.displayName = "Input"
export { Input } export { Input }

View File

@@ -1,24 +1,26 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label" import { Label as LabelPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Label({ const labelVariants = cva(
className, "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
...props )
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( const Label = React.forwardRef<
<LabelPrimitive.Root React.ElementRef<typeof LabelPrimitive.Root>,
data-slot="label" React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
className={cn( VariantProps<typeof labelVariants>
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", >(({ className, ...props }, ref) => (
className <LabelPrimitive.Root
)} ref={ref}
{...props} className={cn(labelVariants(), className)}
/> {...props}
) />
} ))
Label.displayName = LabelPrimitive.Root.displayName
export { Label } export { Label }

View File

@@ -1,274 +0,0 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -1,168 +0,0 @@
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 { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
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"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -1,120 +1,110 @@
import * as React from "react" import * as React from "react"
import { import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button" import { ButtonProps, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) { const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
return ( <nav
<nav role="navigation"
role="navigation" aria-label="pagination"
aria-label="pagination" className={cn("mx-auto flex w-full justify-center", className)}
data-slot="pagination" {...props}
className={cn("mx-auto flex w-full justify-center", className)} />
{...props} )
/> Pagination.displayName = "Pagination"
)
}
function PaginationContent({ const PaginationContent = React.forwardRef<
className, HTMLUListElement,
...props React.ComponentProps<"ul">
}: React.ComponentProps<"ul">) { >(({ className, ...props }, ref) => (
return ( <ul
<ul ref={ref}
data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)}
className={cn("flex flex-row items-center gap-1", className)} {...props}
{...props} />
/> ))
) PaginationContent.displayName = "PaginationContent"
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) { const PaginationItem = React.forwardRef<
return <li data-slot="pagination-item" {...props} /> HTMLLIElement,
} React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> & } & Pick<ButtonProps, "size"> &
React.ComponentProps<"a"> React.ComponentProps<"a">
function PaginationLink({ const PaginationLink = ({
className, className,
isActive, isActive,
size = "icon", size = "icon",
...props ...props
}: PaginationLinkProps) { }: PaginationLinkProps) => (
return ( <a
<a aria-current={isActive ? "page" : undefined}
aria-current={isActive ? "page" : undefined} className={cn(
data-slot="pagination-link" buttonVariants({
data-active={isActive} variant: isActive ? "outline" : "ghost",
className={cn( size,
buttonVariants({ }),
variant: isActive ? "outline" : "ghost", className
size, )}
}), {...props}
className />
)} )
{...props} PaginationLink.displayName = "PaginationLink"
/>
)
}
function PaginationPrevious({ const PaginationPrevious = ({
className, className,
...props ...props
}: React.ComponentProps<typeof PaginationLink>) { }: React.ComponentProps<typeof PaginationLink>) => (
return ( <PaginationLink
<PaginationLink aria-label="Go to previous page"
aria-label="Go to previous page" size="default"
size="default" className={cn("gap-1 pl-2.5", className)}
className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}
{...props} >
> <ChevronLeft className="h-4 w-4" />
<ChevronLeftIcon /> <span>Previous</span>
<span className="hidden sm:block">Previous</span> </PaginationLink>
</PaginationLink> )
) PaginationPrevious.displayName = "PaginationPrevious"
}
function PaginationNext({ const PaginationNext = ({
className, className,
...props ...props
}: React.ComponentProps<typeof PaginationLink>) { }: React.ComponentProps<typeof PaginationLink>) => (
return ( <PaginationLink
<PaginationLink aria-label="Go to next page"
aria-label="Go to next page" size="default"
size="default" className={cn("gap-1 pr-2.5", className)}
className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}
{...props} >
> <span>Next</span>
<span className="hidden sm:block">Next</span> <ChevronRight className="h-4 w-4" />
<ChevronRightIcon /> </PaginationLink>
</PaginationLink> )
) PaginationNext.displayName = "PaginationNext"
}
function PaginationEllipsis({ const PaginationEllipsis = ({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<"span">) => (
return ( <span
<span aria-hidden
aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)}
data-slot="pagination-ellipsis" {...props}
className={cn("flex size-9 items-center justify-center", className)} >
{...props} <MoreHorizontal className="h-4 w-4" />
> <span className="sr-only">More pages</span>
<MoreHorizontalIcon className="size-4" /> </span>
<span className="sr-only">More pages</span> )
</span> PaginationEllipsis.displayName = "PaginationEllipsis"
)
}
export { export {
Pagination, Pagination,

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -1,29 +1,28 @@
"use client"
import * as React from "react" import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress" import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Progress({ const Progress = React.forwardRef<
className, React.ElementRef<typeof ProgressPrimitive.Root>,
value, React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
...props >(({ className, value, ...props }, ref) => (
}: React.ComponentProps<typeof ProgressPrimitive.Root>) { <ProgressPrimitive.Root
return ( ref={ref}
<ProgressPrimitive.Root className={cn(
data-slot="progress" "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className={cn( className
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", )}
className {...props}
)} >
{...props} <ProgressPrimitive.Indicator
> className="h-full w-full flex-1 bg-primary transition-all"
<ProgressPrimitive.Indicator style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
data-slot="progress-indicator" />
className="bg-primary h-full w-full flex-1 transition-all" </ProgressPrimitive.Root>
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} ))
/> Progress.displayName = ProgressPrimitive.Root.displayName
</ProgressPrimitive.Root>
)
}
export { Progress } export { Progress }

View File

@@ -1,7 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { CircleIcon } from "lucide-react" import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -43,3 +41,4 @@ function RadioGroupItem({
} }
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem }

View File

@@ -1,5 +1,5 @@
import * as React from "react" "use client"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels" import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -7,12 +7,12 @@ import { cn } from "@/lib/utils"
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) { }: ResizablePrimitive.GroupProps) {
return ( return (
<ResizablePrimitive.PanelGroup <ResizablePrimitive.Group
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full aria-[orientation=vertical]:flex-col",
className className
)} )}
{...props} {...props}
@@ -20,9 +20,7 @@ function ResizablePanelGroup({
) )
} }
function ResizablePanel({ function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
} }
@@ -30,25 +28,23 @@ function ResizableHandle({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean withHandle?: boolean
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", "bg-border focus-visible:ring-ring ring-offset-background relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className className
)} )}
{...props} {...props}
> >
{withHandle && ( {withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border"> <div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
<GripVerticalIcon className="size-2.5" />
</div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.Separator>
) )
} }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -1,58 +0,0 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,183 +1,159 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select" import { Select as SelectPrimitive } from "radix-ui"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Select({ const Select = SelectPrimitive.Root
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ const SelectGroup = SelectPrimitive.Group
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ const SelectValue = SelectPrimitive.Value
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({ const SelectTrigger = React.forwardRef<
className, React.ElementRef<typeof SelectPrimitive.Trigger>,
size = "default", React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
children, >(({ className, children, ...props }, ref) => (
...props <SelectPrimitive.Trigger
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { ref={ref}
size?: "sm" | "default" className={cn(
}) { "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
return ( className
<SelectPrimitive.Trigger )}
data-slot="select-trigger" {...props}
data-size={size} >
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position}
{...props} {...props}
> >
{children} <SelectScrollUpButton />
<SelectPrimitive.Icon asChild> <SelectPrimitive.Viewport
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "p-1",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
className
)} )}
position={position}
{...props}
> >
<SelectScrollUpButton /> {children}
<SelectPrimitive.Viewport </SelectPrimitive.Viewport>
className={cn( <SelectScrollDownButton />
"p-1", </SelectPrimitive.Content>
position === "popper" && </SelectPrimitive.Portal>
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" ))
)} SelectContent.displayName = SelectPrimitive.Content.displayName
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ const SelectLabel = React.forwardRef<
className, React.ElementRef<typeof SelectPrimitive.Label>,
...props React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
}: React.ComponentProps<typeof SelectPrimitive.Label>) { >(({ className, ...props }, ref) => (
return ( <SelectPrimitive.Label
<SelectPrimitive.Label ref={ref}
data-slot="select-label" className={cn("px-2 py-1.5 text-sm font-semibold", className)}
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}
{...props} />
/> ))
) SelectLabel.displayName = SelectPrimitive.Label.displayName
}
function SelectItem({ const SelectItem = React.forwardRef<
className, React.ElementRef<typeof SelectPrimitive.Item>,
children, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
...props >(({ className, children, ...props }, ref) => (
}: React.ComponentProps<typeof SelectPrimitive.Item>) { <SelectPrimitive.Item
return ( ref={ref}
<SelectPrimitive.Item className={cn(
data-slot="select-item" "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className={cn( className
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", )}
className {...props}
)} >
{...props} <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
> <SelectPrimitive.ItemIndicator>
<span className="absolute right-2 flex size-3.5 items-center justify-center"> <Check className="h-4 w-4" />
<SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> </span>
</SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</span> </SelectPrimitive.Item>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> ))
</SelectPrimitive.Item> SelectItem.displayName = SelectPrimitive.Item.displayName
)
}
function SelectSeparator({ const SelectSeparator = React.forwardRef<
className, React.ElementRef<typeof SelectPrimitive.Separator>,
...props React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { >(({ className, ...props }, ref) => (
return ( <SelectPrimitive.Separator
<SelectPrimitive.Separator ref={ref}
data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}
{...props} />
/> ))
) SelectSeparator.displayName = SelectPrimitive.Separator.displayName
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export { export {
Select, Select,
SelectContent,
SelectGroup, SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue, SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
} }

View File

@@ -1,28 +1,29 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ const Separator = React.forwardRef<
className, React.ElementRef<typeof SeparatorPrimitive.Root>,
orientation = "horizontal", React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
decorative = true, >(
...props (
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { { className, orientation = "horizontal", decorative = true, ...props },
return ( ref
) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot="separator" ref={ref}
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className
)} )}
{...props} {...props}
/> />
) )
} )
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator } export { Separator }

View File

@@ -1,132 +1,135 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog" import { Dialog as SheetPrimitive } from "radix-ui"
import { XIcon } from "lucide-react" import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { const Sheet = SheetPrimitive.Root
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ const SheetTrigger = SheetPrimitive.Trigger
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ const SheetClose = SheetPrimitive.Close
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ const SheetPortal = SheetPrimitive.Portal
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 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",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<SheetPrimitive.Overlay className={cn(
data-slot="sheet-overlay" "flex flex-col space-y-2 text-center sm:text-left",
className={cn( className
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", )}
className {...props}
)} />
{...props} )
/> SheetHeader.displayName = "SheetHeader"
)
}
function SheetContent({ const SheetFooter = ({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<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",
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" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
<SheetPrimitive.Title className={cn(
data-slot="sheet-title" "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className={cn("text-foreground font-semibold", className)} className
{...props} )}
/> {...props}
) />
} )
SheetFooter.displayName = "SheetFooter"
function SheetDescription({ const SheetTitle = React.forwardRef<
className, React.ElementRef<typeof SheetPrimitive.Title>,
...props React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
}: React.ComponentProps<typeof SheetPrimitive.Description>) { >(({ className, ...props }, ref) => (
return ( <SheetPrimitive.Title
<SheetPrimitive.Description ref={ref}
data-slot="sheet-description" className={cn("text-lg font-semibold text-foreground", className)}
className={cn("text-muted-foreground text-sm", className)} {...props}
{...props} />
/> ))
) SheetTitle.displayName = SheetPrimitive.Title.displayName
}
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger, SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot as SlotPrimitive } from "radix-ui"
import { cva, VariantProps } from "class-variance-authority" import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react"
@@ -26,7 +26,7 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem"
@@ -69,9 +69,25 @@ function SidebarProvider({
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false)
// Helper to read cookie value
const getCookieValue = (name: string): boolean | null => {
if (typeof document === "undefined") return null
const value = document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1]
return value === "true" ? true : value === "false" ? false : null
}
// Read initial state from cookie, fallback to defaultOpen
const getInitialState = () => {
const cookieValue = getCookieValue(SIDEBAR_COOKIE_NAME)
return cookieValue !== null ? cookieValue : defaultOpen
}
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(getInitialState)
const open = openProp ?? _open const open = openProp ?? _open
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
@@ -398,7 +414,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? SlotPrimitive.Slot : "div"
return ( return (
<Comp <Comp
@@ -419,7 +435,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? SlotPrimitive.Slot : "button"
return ( return (
<Comp <Comp
@@ -508,7 +524,7 @@ function SidebarMenuButton({
isActive?: boolean isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? SlotPrimitive.Slot : "button"
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar()
const button = ( const button = (
@@ -554,7 +570,7 @@ function SidebarMenuAction({
asChild?: boolean asChild?: boolean
showOnHover?: boolean showOnHover?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? SlotPrimitive.Slot : "button"
return ( return (
<Comp <Comp
@@ -677,7 +693,7 @@ function SidebarMenuSubButton({
size?: "sm" | "md" size?: "sm" | "md"
isActive?: boolean isActive?: boolean
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? SlotPrimitive.Slot : "a"
return ( return (
<Comp <Comp

View File

@@ -1,10 +1,12 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
data-slot="skeleton" className={cn("animate-pulse rounded-md bg-primary/10", className)}
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) )

View File

@@ -1,63 +1,26 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider" import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Slider({ const Slider = React.forwardRef<
className, React.ElementRef<typeof SliderPrimitive.Root>,
defaultValue, React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
value, >(({ className, ...props }, ref) => (
min = 0, <SliderPrimitive.Root
max = 100, ref={ref}
...props className={cn(
}: React.ComponentProps<typeof SliderPrimitive.Root>) { "relative flex w-full touch-none select-none items-center",
const _values = React.useMemo( className
() => )}
Array.isArray(value) {...props}
? value >
: Array.isArray(defaultValue) <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
? defaultValue <SliderPrimitive.Range className="absolute h-full bg-primary" />
: [min, max], </SliderPrimitive.Track>
[value, defaultValue, min, max] <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
) </SliderPrimitive.Root>
))
return ( Slider.displayName = SliderPrimitive.Root.displayName
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<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"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider } export { Slider }

View File

@@ -1,5 +1,16 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
@@ -8,17 +19,29 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties } as React.CSSProperties
} }
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: "group", toast: "group toast",
icon: "group-data-[type=error]:!text-red-500 group-data-[type=success]:!text-green-500 group-data-[type=warning]:!text-amber-500 group-data-[type=info]:!text-sky-500", icon: "group-data-[type=error]:!text-red-500 group-data-[type=success]:!text-green-500 group-data-[type=warning]:!text-amber-500 group-data-[type=info]:!text-sky-500",
description: "group-[.toast]:!text-muted-foreground",
actionButton: "group-[.toast]:!bg-primary group-[.toast]:!text-primary-foreground",
cancelButton: "group-[.toast]:!bg-muted group-[.toast]:!text-muted-foreground",
closeButton: "hover:group-[.toast]:!bg-background hover:group-[.toast]:!text-foreground",
}, },
}} }}
{...props} {...props}

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -1,31 +1,27 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch" import { Switch as SwitchPrimitives } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Switch({ const Switch = React.forwardRef<
className, React.ElementRef<typeof SwitchPrimitives.Root>,
...props React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
}: React.ComponentProps<typeof SwitchPrimitive.Root>) { >(({ className, ...props }, ref) => (
return ( <SwitchPrimitives.Root
<SwitchPrimitive.Root className={cn(
data-slot="switch" "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
className
)} )}
{...props} />
> </SwitchPrimitives.Root>
<SwitchPrimitive.Thumb ))
data-slot="switch-thumb" Switch.displayName = SwitchPrimitives.Root.displayName
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"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch } export { Switch }

View File

@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,66 +1,53 @@
"use client"
import * as React from "react" import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs" import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Tabs({ const Tabs = TabsPrimitive.Root
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({ const TabsList = React.forwardRef<
className, React.ElementRef<typeof TabsPrimitive.List>,
...props React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
}: React.ComponentProps<typeof TabsPrimitive.List>) { >(({ className, ...props }, ref) => (
return ( <TabsPrimitive.List
<TabsPrimitive.List ref={ref}
data-slot="tabs-list" className={cn(
className={cn( "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", className
className )}
)} {...props}
{...props} />
/> ))
) TabsList.displayName = TabsPrimitive.List.displayName
}
function TabsTrigger({ const TabsTrigger = React.forwardRef<
className, React.ElementRef<typeof TabsPrimitive.Trigger>,
...props React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) { >(({ className, ...props }, ref) => (
return ( <TabsPrimitive.Trigger
<TabsPrimitive.Trigger ref={ref}
data-slot="tabs-trigger" className={cn(
className={cn( "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className
className )}
)} {...props}
{...props} />
/> ))
) TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
}
function TabsContent({ const TabsContent = React.forwardRef<
className, React.ElementRef<typeof TabsPrimitive.Content>,
...props React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
}: React.ComponentProps<typeof TabsPrimitive.Content>) { >(({ className, ...props }, ref) => (
return ( <TabsPrimitive.Content
<TabsPrimitive.Content ref={ref}
data-slot="tabs-content" className={cn(
className={cn("flex-1 outline-none", className)} "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{...props} className
/> )}
) {...props}
} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -2,17 +2,21 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
data-slot="textarea"
className={cn( className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref}
{...props} {...props}
/> />
) )
} })
Textarea.displayName = "Textarea"
export { Textarea } export { Textarea }

Some files were not shown because too many files have changed in this diff Show More