1
1
mirror of https://github.com/neosubhamoy/neodlp.git synced 2026-02-05 03:02:23 +05:30

78 Commits

145 changed files with 15587 additions and 8910 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

3
.gitattributes vendored
View File

@@ -1,2 +1 @@
src-tauri/binaries/* filter=lfs diff=lfs merge=lfs -text
src-tauri/resources/binaries/* filter=lfs diff=lfs merge=lfs -text
* text=auto eol=lf

View File

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

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: neosubhamoy
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**OS info (required):**
- OS: [e.g. Windows 11]
- OS Architecture: [e.g. x64]
- OS Version: [e.g. 25H2 26200.6899]
**App info (required):**
- NeoDLP Version: [e.g. 0.3.1]
- YT-DLP Version: [e.g. 2025.10.18.232824]
- NeoDLP Installation Mode: [e.g. msi, exe, winget]
**App logs (required):**
Paste the full app session logs here
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE REQUEST]"
labels: feature request
assignees: neosubhamoy
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

31
.github/ISSUE_TEMPLATE/test_result.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Test result
about: Share your test results
title: "[TEST RESULT]"
labels: test result
assignees: neosubhamoy
---
**Describe the result**
A clear and concise description of what's the final result of your testing.
**Did you noticed any problem?**
Mention if you noticed any problems or inconsistencies during the testing
**Screenshots**
If applicable, add screenshots to help explain the results.
**What's working (required):**
- [ ] Package installation
- [ ] Downloads
- [ ] Settings and configurations
- [ ] Browser integration
**Test environment (required):**
- OS: [e.g. Windows 11]
- OS Architecture: [e.g. x64]
- OS Version: [e.g. 25H2 26200.6899]
- NeoDLP Version: [e.g. 0.3.1]
- YT-DLP Version: [e.g. 2025.10.18.232824]
- NeoDLP Installation Mode: [e.g. msi, exe, winget]

16
.github/images/banner.svg vendored Normal file
View File

@@ -0,0 +1,16 @@
<svg width="600" height="130" viewBox="0 0 600 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_2)">
<path d="M86.9062 11H21.0938C9.44399 11 0 20.444 0 32.0938V97.9062C0 109.556 9.44399 119 21.0938 119H86.9062C98.556 119 108 109.556 108 97.9062V32.0938C108 20.444 98.556 11 86.9062 11Z" fill="url(#paint0_linear_3_2)"/>
<path d="M55.8196 96.5455C54.7881 97.5856 53.1065 97.5856 52.075 96.5455L27.028 71.2863C25.3778 69.6221 26.5566 66.793 28.9002 66.793H78.9943C81.3379 66.793 82.5168 69.6221 80.8666 71.2863L55.8196 96.5455Z" fill="#FAFAFA"/>
<path d="M67.8164 34.4141H40.0781C38.6219 34.4141 37.4414 35.5946 37.4414 37.0508V68.2695C37.4414 69.7257 38.6219 70.9062 40.0781 70.9062H67.8164C69.2726 70.9062 70.4531 69.7257 70.4531 68.2695V37.0508C70.4531 35.5946 69.2726 34.4141 67.8164 34.4141Z" fill="#FAFAFA"/>
</g>
<defs>
<linearGradient id="paint0_linear_3_2" x1="13.6582" y1="26.6621" x2="97.1367" y2="102.02" gradientUnits="userSpaceOnUse">
<stop stop-color="#4444FF"/>
<stop offset="1" stop-color="#FF43D0"/>
</linearGradient>
<clipPath id="clip0_3_2">
<rect width="108" height="108" fill="white" transform="translate(0 11)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
.github/images/completed-downloads.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
.github/images/downloader.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

138
.github/images/mockup.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 541 KiB

BIN
.github/images/ongoing-downloads.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
.github/images/settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -2,7 +2,7 @@ on:
push:
tags:
- 'v*.*.*'
name: 🚀 Release on GitHub
jobs:
release:
@@ -14,31 +14,27 @@ jobs:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin --config ./src-tauri/tauri.macos-aarch64.conf.json'
arch: 'aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin --config ./src-tauri/tauri.macos-x86_64.conf.json'
arch: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
arch: ''
args: '--target x86_64-unknown-linux-gnu --config ./src-tauri/tauri.linux-x86_64.conf.json'
- platform: 'ubuntu-22.04-arm'
args: '--target aarch64-unknown-linux-gnu --config ./src-tauri/tauri.linux-aarch64.conf.json'
- platform: 'windows-latest'
args: ''
arch: ''
runs-on: ${{ matrix.platform }}
steps:
- name: 🚚 Checkout repository
uses: actions/checkout@v4
with:
lfs: true
uses: actions/checkout@v5
- name: 🛠️ Install dependencies
if: matrix.platform == 'ubuntu-22.04'
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 📦 Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
@@ -56,6 +52,9 @@ jobs:
- name: 🛠️ Install frontend dependencies
run: npm install
- name: 📥 Download binaries
run: npm run download
- name: 📄 Read and Process CHANGELOG (Unix)
if: matrix.platform != 'windows-latest'
id: changelog_unix
@@ -64,12 +63,12 @@ jobs:
if [ -f CHANGELOG.md ]; then
# Extract version number from tag
VERSION_NUM=$(echo "${{ github.ref_name }}" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$/\1/')
# Read and replace placeholders
CONTENT=$(cat CHANGELOG.md)
CONTENT=${CONTENT//<release_tag>/${{ github.ref_name }}}
CONTENT=${CONTENT//<version>/$VERSION_NUM}
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -85,12 +84,12 @@ jobs:
if (Test-Path "CHANGELOG.md") {
# Extract version number from tag
$version_num = "${{ github.ref_name }}" -replace '^v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$','$1'
# Read and replace placeholders
$content = Get-Content -Path CHANGELOG.md -Raw
$content = $content -replace '<release_tag>', "${{ github.ref_name }}"
$content = $content -replace '<version>', "$version_num"
"content<<EOF" >> $env:GITHUB_OUTPUT
$content >> $env:GITHUB_OUTPUT
"EOF" >> $env:GITHUB_OUTPUT
@@ -102,7 +101,6 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_ARCH: ${{ matrix.arch }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
@@ -113,4 +111,4 @@ jobs:
prerelease: false
includeUpdaterJson: true
updaterJsonPreferNsis: true
args: ${{ matrix.args }}
args: ${{ matrix.args }}

19
.gitignore vendored
View File

@@ -1,3 +1,14 @@
node_modules
dist
dist-ssr
*.local
.github/workflows/.secrets
/target/
src-tauri/binaries/*
!src-tauri/binaries/.gitkeep
src-tauri/resources/downloads/*
!src-tauri/resources/downloads/.gitkeep
# Logs
logs
*.log
@@ -7,12 +18,6 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.github/workflows/.secrets
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -22,4 +27,4 @@ dist-ssr
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?

View File

@@ -1,39 +1,56 @@
### ✨ Changelog
- DOWNLOADER: Introduced 'Combine' download mode (Now, You can combine a video and audio stream of your choice)
- SETTINGS: Added global video/audio file format selection option (Available Formats: MP4, WEBM, MKV, M4A, OPUS, MP3)
- SETTINGS: Added video/audio file metadata embeding option
- SETTINGS: Added thumbnail embeding option in audio files (as cover art)
- SETTINGS: Added re-encode video over remuxing option (when file format convertion is needed)
- SETTINGS: Added strict downloadablity check option
- SETTINGS: Added download speed rate limit option
- SETTINGS: Added download max retries option
- SETTINGS: Added temporary download folder cleanup option
- UI: Improved 'Settings' ui/layout with categories (tabs)
- UI: Merged 'Extension' sidebar tab within 'Settings' (Settings > Extension > Install)
- UI: Improved 'Library' ui/layout with tabs
- UI: Added 'Stop' all ongoing downloads button in 'Library'
- UI: Renamed settings 'General' tab to 'Application' ('General' is now a sub-category of 'Application' tab)
- UI: Improved all alert dialog messages (for better undestanding/UX)
- FIXED: Unexpected crashing of yt-dlp causing downloads to stuck on a unrevocable state (Now, coresponding download will be 'paused' on detection of unexpected yt-dlp crash)
- FIXED: Broken app updater progress bar/percentage (also improved the update notification card)
- Lots of other minor fixes and improvements
- Added support for selective-batch/full-playlist download
- Added support for selecting multiple audio streams on combine mode
- Added support for embedding original auto-generated subtitles
- Added option to crop thubnails to square (1:1) before embedding
- Added 'errored' download state (to better identify errored downloads, which you can retry later)
- Added app interface color scheme options on appearance settings
- Added app info page under settings
- Added copy/clear log buttons in log viewer
- Added sponsorblock 'hook' category
- Fixed sidebar state not persisting on app re-start
- Fixed linux native (deb/rpm) installation downloading appimage update
- Bumped up shadcn/ui to v3.5 and lots of under the hood ui improvements
- Optimized database and backend performance
- Lots of other fixes and improvements
### 📝 Notes
> ⚠️ Linux Users: Make sure yt-dlp 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)
> [!CAUTION]
> This update introduces few breaking changes! 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.
> [!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)
> This is an Un-Signed Build (Windows doesn't trust this Certificate so, it may flag this as malicious software, in that case, disable Windows SmartScreen and Defender, install it, and then re-enable them)
> This is an Un-Signed Build (MacOS doesn't trust this Certificate so, it may flag this as from 'unverified developer' and prevent it from opening, in that case, open Settings and allow it from 'Settings > Privacy and Security' section to get started)
### 📦 Shipped Binaries
| yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno |
| :---- | :---- | :---- | :---- | :---- |
| v2026.01.19.233146 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.6.5 |
> ‼️ 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)
> ‼️ MacOS builds (dmg, app) does not ships with `aria2c`, If you want to use [aria2](https://formulae.brew.sh/formula/aria2) install it via [homebrew](https://brew.sh)
### ⬇️ Download Section
| Arch\OS | Windows (msi) ⬆️ | Windows (exe) ⬆️ | Linux (deb) | Linux (rpm) | MacOS (dmg) ⬆️ | MacOS (app) ⬆️ |
| :---- | :---- | :---- | :---- | :---- | :---- | :---- |
| 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>_x64.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_x64.app.tar.gz) |
| ARM64 | N/A | N/A | N/A | 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) |
| 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.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.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) |
> ⬆️ icon indicates this packaging format supports in-built app-updater
> ⚠️ 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
> 🪟 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)
> ⚠️ 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
Copyright (c) 2025 Subhamoy Biswas
Copyright (c) 2025 - Present Subhamoy Biswas
Permission is hereby granted, free of charge, to any person obtaining a copy
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
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
SOFTWARE.
SOFTWARE.

220
README.md
View File

@@ -1,77 +1,207 @@
# NeoDLP - (Neo Downloader Plus)
![NeoDLP](./.github/images/banner.svg)
# NeoDLP - Neo Downloader Plus
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration
[![status](https://img.shields.io/badge/status-active-brightgreen.svg?style=flat)](https://github.com/neosubhamoy/neodlp)
[![github tag](https://img.shields.io/github/v/tag/neosubhamoy/neodlp?color=yellow)](https://github.com/neosubhamoy/neodlp)
[![PRs](https://img.shields.io/badge/PRs-welcome-blue.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 downloads](https://img.shields.io/github/downloads/neosubhamoy/neodlp/total?style=for-the-badge)](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)
[![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]
> **🥰 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!**
### 💻 Supported Platforms
[![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
- 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 in your preffered format (MP4, WEBM, MKV, MP3 etc.)
- Supports both Video and Playlist download
- Supports Combining Video, Audio streams of your choice
- Supports Multi-Lingual Subtitle/Caption (CC) embeding
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
- SponsorBlock support (mark/remove video segments)
- Network controls (proxy, rate limit etc.)
- Highly customizable and many more...😉
## 🧩 Browser Integration
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:
- 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 (Search with Neo Downloader Plus - Link, Selection, Media Source)
## 👀 Sneak Peek
![NeoDLP-Mockup](./.github/images/mockup.svg)
| Downloader | Completed Downloads | Ongoing Downloads | Settings |
| :---- | :---- | :---- | :---- |
| ![Downloader](./.github/images/downloader.png) | ![Completed-Downloads](./.github/images/completed-downloads.png) | ![Ongoing-Downloads](./.github/images/ongoing-downloads.png) | ![Settings](./.github/images/settings.png) |
## 💻 Supported Platforms
- Windows (10 / 11)
- Linux (Debian / Fedora / Arch Linux base)
- MacOS (>10.3)
- Linux (Debian / Fedora / RHEL / SUSE / Arch Linux base)
- MacOS (>11)
### 🌐 Supported Sites
## 🤝 External Dependencies
- All [Supported Sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) by [yt-dlp](https://github.com/yt-dlp/yt-dlp) **(2.5K+)**
- [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) [LGPLv2.1+] - Used for video/audio post-processing
- [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) [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))
### 🧩 External Dependencies
## System Pre-Requirements
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The core CLI Tool used to download Video/Audio from the Web
- [FFmpeg](https://www.ffmpeg.org) - Used for Video/Audio Post-processing
- **Windows:** [Microsoft Visual C++ Redistributable 2015+](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) `winget install Microsoft.VCRedist.2015+.x64` (Will be auto-installed if you install NeoDLP via winget)
- **MacOS:** XCode Command Line Tools `xcode-select --install` (Mostly, comes pre-installed on modern macos, still if you encounter any issue then try installing it manually)
- **Linux:** Most linux packages comes with pre-defined system dependencies which will be auto installed 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. also, if you prefer to install dependencies manually [follow this](https://v2.tauri.app/start/prerequisites/#linux))
### ⬇️ 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
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) |
| ARM64 | ❌ N/A | ❌ N/A | ✅ [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) |
> [!NOTE]
> x86_64 Windows 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)
| Platform (OS) | Distribution Channel | Installation Command / Instruction |
| :---- | :---- | :---- |
| Windows x86_64 | WinGet | `winget install neodlp` |
| MacOS Universal | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
| Linux x86_64 (Arch Linux) | AUR | `yay -S neodlp` |
| Windows x86_64 / ARM64 | WinGet | `winget install neosubhamoy.neodlp` |
| 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` |
| Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` |
### ⚡ Technologies Used
## 🧪 Package Testing Status
![Tauri](https://img.shields.io/badge/tauri-%2324C8DB.svg?style=for-the-badge&logo=tauri&logoColor=%23FFFFFF)
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
![ShadCnUi](https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white)
Though NeoDLP is supported on most platforms but not all packages are tested on all platforms, to save some time (and brain cells) and ship the software as fast as possible! Current test coverage is given below. So, untested packages may have issues, test it yourself and always feel free to report any issue on github.
### 🛠️ Contributing / Building from Source
> [!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 😊)
Want to be part of this? Feel free to contribute...!! Pull Requests are always welcome...!! (^_^) Follow these simple steps to start building:
<details>
<summary>Test Coverage</summary>
* Make sure to install [Rust](https://www.rust-lang.org/tools/install), [Node.js](https://nodejs.org/en), [Git](https://git-scm.com/downloads) and [Git-LFS](https://git-lfs.com/) before proceeding.
| Platform | Status | Platform | Status |
| :---- | :---- | :---- | :---- |
| Windows 10 (x64) | ✅ Tested | Windows 10 (ARM64) | ⚠️ Untested |
| Windows 11 (x64) | ✅ Tested | Windows 11 (ARM64) | ✅ Tested |
| MacOS 14 (x64) | ✅ Tested | MacOS 14 (ARM64) | ✅ Tested |
| MacOS 15 (x64) | ⚠️ Untested | MacOS 15 (ARM64) | ✅ Tested |
| MacOS 26 (x64) | ⚠️ Untested | MacOS 26 (ARM64) | ✅ Tested |
| Ubuntu 24.04 LTS (x64) | ✅ Tested | Ubuntu 24.04 LTS (ARM64) | ⚠️ Untested |
| Fedora 42 (x64) | ✅ Tested | Fedora 42 (ARM64) | ⚠️ Untested |
| Arch Linux (x64) | ✅ Tested | Arch Linux (ARM64) | ✅ Tested |
| openSUSE 16 (x64) | ⚠️ Untested | openSUSE 16 (ARM64) | ⚠️ Untested |
| RHEL 10 (x64) | ⚠️ Untested | RHEL 10 (ARM64) | ⚠️ Untested |
</details>
## 💝 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...🤗
<a href="https://buymeacoffee.com/neosubhamoy" target="_blank" title="buymeacoffee">
<img src="https://iili.io/JoQ0zN9.md.png" alt="buymeacoffee-orange-badge" style="width: 150px;">
</a>
<br></br>
> [!NOTE]
> You can also donate via UPI by sending donations to this UPI ID directly: **subhamoybiswas636-2@oksbi**
## 🪜 Roadmap
- [x] Add support for yt-dlp
- [x] Add basic settings and customization
- [x] Integrate with browsers
- [x] Add aria2c support
- [x] Add custom command support
- [x] Add full-playlist/batch download support
- [ ] Improve browser integration **(ongoing)**
- [ ] Implement NeoDLP API
- [ ] Build web interface
- [ ] Implement plugin system
- [ ] Add more cool stuffs 😉
## ⚡ Technologies Used
[![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)](https://rust-lang.org)
[![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)](https://www.typescriptlang.org)
[![ShadCnUi](https://img.shields.io/badge/shadcn%2Fui-000000?style=for-the-badge&logo=shadcnui&logoColor=white)](https://ui.shadcn.com)
## 🛠️ Building from Source
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.
* Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform
1. Fork this repo in your github account.
2. Git clone the forked repo in your local machine.
1. Clone this repo in your local machine: `git clone https://github.com/neosubhamoy/neodlp.git`
2. Go inside the cloned project directory: `cd neodlp`
3. Install Node.js dependencies: `npm install`
4. Run development / build process
> ⚠️ Make sure to run the build command once before running the dev command for the first time to avoid build time errors
```code
# for windows and linux users
npm run tauri dev # for development
npm run tauri build # for production build
4. Download required external binaries (for your platform): `npm run download`
5. Run build process (run the command based on your platform and architecture)
```shell
# command for windows users
npm run tauri build # for both x64/ARM64 devices
# 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
# commands for linux users
npm run tauri:build:linux-x64 # for x64 devices
npm run tauri:build:linux-arm64 # for ARM64 devices
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
# commands for macOS users
npm run tauri:build:macos-arm64 # for apple silicon macs
npm run tauri:build:macos-x64 # for intel x86 macs
```
5. 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.
**⭕ Noticed any Bugs or Want to give us some suggetions? Always feel free to open a GitHub Issue. We would love to hear from you...!!**
## 🐞 Bug Report and Discussions
### 📝 License
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:
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.
- 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
- 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)
- Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds)
## ⚖️ License and Usage
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**

2834
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,69 @@
{
"name": "neodlp",
"private": true,
"version": "0.2.0",
"version": "0.4.0",
"description": "Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"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"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.83.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.0",
"@tauri-apps/plugin-sql": "^2.3.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@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/devtools-vite": "^0.4.1",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-pacer": "^0.19.3",
"@tanstack/react-pacer-devtools": "^0.5.2",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-sql": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -55,33 +71,34 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.3",
"react-router-dom": "^7.6.3",
"recharts": "^2.15.4",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"ulid": "^3.0.2",
"vaul": "^1.1.2",
"zod": "^3.25.76",
"zustand": "^5.0.6"
"zod": "^4.3.5",
"zustand": "^5.0.10"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tauri-apps/cli": "^2",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2.9.6",
"@types/node": "^25.0.9",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
}

View File

@@ -5,11 +5,11 @@ import { execSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
// Define array of binary source directories
const binSrcDirs = [
path.join(__dirname, 'src-tauri', 'binaries'),
path.join(__dirname, 'src-tauri', 'resources', 'binaries'),
path.join(projectRoot, 'src-tauri', 'binaries'),
];
function makeFilesExecutable() {
@@ -35,7 +35,7 @@ function makeFilesExecutable() {
count++;
}
}
console.log(`Successfully made ${count} files executable in ${binSrc}`);
totalCount += count;
successDirs++;
@@ -47,5 +47,5 @@ function makeFilesExecutable() {
console.log(`\nSummary: Made ${totalCount} files executable across ${successDirs} directories`);
}
console.log(`RUNNING: 🛠️ Build Script makeFilesExecutable.js`);
makeFilesExecutable();
console.log(`RUNNING: 🛠️ Build Script --> chmod.js`);
makeFilesExecutable();

442
scripts/download-bins.js Normal file
View File

@@ -0,0 +1,442 @@
import os from 'os';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const downloadDir = path.join(projectRoot, 'src-tauri', 'resources', 'downloads');
const binDir = path.join(projectRoot, 'src-tauri', 'binaries');
const platform = os.platform();
const targetPlatform = process.argv[2];
const targetBin = process.argv[3];
const versions = {
'yt-dlp': 'latest',
'ffmpeg-ffprobe': 'latest',
'deno': 'latest',
'aria2c': '1.37.0',
};
const binaries = {
'yt-dlp': [
{
name: 'yt-dlp-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp.exe`,
src: path.join(downloadDir, 'yt-dlp-x86_64-pc-windows-msvc.exe'),
dest: [
path.join(binDir, 'yt-dlp-x86_64-pc-windows-msvc.exe')
],
archive: null,
cleanup: [
path.join(downloadDir, 'yt-dlp-x86_64-pc-windows-msvc.exe')
]
},
{
name: 'yt-dlp-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_linux`,
src: path.join(downloadDir, 'yt-dlp-x86_64-unknown-linux-gnu'),
dest: [
path.join(binDir, 'yt-dlp-x86_64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'yt-dlp-x86_64-unknown-linux-gnu')
]
},
{
name: 'yt-dlp-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_linux_aarch64`,
src: path.join(downloadDir, 'yt-dlp-aarch64-unknown-linux-gnu'),
dest: [
path.join(binDir, 'yt-dlp-aarch64-unknown-linux-gnu')
],
archive: null,
cleanup: [
path.join(downloadDir, 'yt-dlp-aarch64-unknown-linux-gnu')
]
},
{
name: 'yt-dlp-universal-apple-darwin',
platform: 'darwin',
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_macos`,
src: path.join(downloadDir, 'yt-dlp-universal-apple-darwin'),
dest: [
path.join(binDir, 'yt-dlp-x86_64-apple-darwin'),
path.join(binDir, 'yt-dlp-aarch64-apple-darwin')
],
archive: null,
cleanup: [
path.join(downloadDir, 'yt-dlp-universal-apple-darwin')
]
},
],
'ffmpeg-ffprobe': [
{
name: 'ffmpeg-ffprobe-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-win64-gpl.zip`,
src: path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl', 'bin', 'ffmpeg.exe'),
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl', 'bin', 'ffprobe.exe')
],
binDest: [
path.join(binDir, 'ffmpeg-x86_64-pc-windows-msvc.exe'),
path.join(binDir, 'ffprobe-x86_64-pc-windows-msvc.exe')
]
},
cleanup: [
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl.zip'),
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl')
]
},
{
name: 'ffmpeg-ffprobe-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-linux64-gpl.tar.xz`,
src: path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl.tar.xz'),
dest: null,
archive: {
type: 'tar.xz',
binSrc: [
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl', 'bin', 'ffmpeg'),
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl', 'bin', 'ffprobe')
],
binDest: [
path.join(binDir, 'ffmpeg-x86_64-unknown-linux-gnu'),
path.join(binDir, 'ffprobe-x86_64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl.tar.xz'),
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl')
]
},
{
name: 'ffmpeg-ffprobe-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-linuxarm64-gpl.tar.xz`,
src: path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz'),
dest: null,
archive: {
type: 'tar.xz',
binSrc: [
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl', 'bin', 'ffmpeg'),
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl', 'bin', 'ffprobe')
],
binDest: [
path.join(binDir, 'ffmpeg-aarch64-unknown-linux-gnu'),
path.join(binDir, 'ffprobe-aarch64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz'),
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')
]
}
],
'deno': [
{
name: 'deno-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-pc-windows-msvc.zip`,
src: path.join(downloadDir, 'deno-x86_64-pc-windows-msvc.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'deno.exe')
],
binDest: [
path.join(binDir, 'deno-x86_64-pc-windows-msvc.exe')
]
},
cleanup: [
path.join(downloadDir, 'deno-x86_64-pc-windows-msvc.zip'),
path.join(downloadDir, 'deno.exe')
]
},
{
name: 'deno-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-unknown-linux-gnu.zip`,
src: path.join(downloadDir, 'deno-x86_64-unknown-linux-gnu.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'deno')
],
binDest: [
path.join(binDir, 'deno-x86_64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, 'deno-x86_64-unknown-linux-gnu.zip'),
path.join(downloadDir, 'deno')
]
},
{
name: 'deno-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-aarch64-unknown-linux-gnu.zip`,
src: path.join(downloadDir, 'deno-aarch64-unknown-linux-gnu.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'deno')
],
binDest: [
path.join(binDir, 'deno-aarch64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, 'deno-aarch64-unknown-linux-gnu.zip'),
path.join(downloadDir, 'deno')
]
},
{
name: 'deno-x86_64-apple-darwin',
platform: 'darwin',
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-apple-darwin.zip`,
src: path.join(downloadDir, 'deno-x86_64-apple-darwin.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'deno')
],
binDest: [
path.join(binDir, 'deno-x86_64-apple-darwin')
]
},
cleanup: [
path.join(downloadDir, 'deno-x86_64-apple-darwin.zip'),
path.join(downloadDir, 'deno')
]
},
{
name: 'deno-aarch64-apple-darwin',
platform: 'darwin',
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-aarch64-apple-darwin.zip`,
src: path.join(downloadDir, 'deno-aarch64-apple-darwin.zip'),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, 'deno')
],
binDest: [
path.join(binDir, 'deno-aarch64-apple-darwin')
]
},
cleanup: [
path.join(downloadDir, 'deno-aarch64-apple-darwin.zip'),
path.join(downloadDir, 'deno')
]
}
],
'aria2c': [
{
name: 'aria2c-x86_64-pc-windows-msvc',
platform: 'win32',
url: `https://github.com/aria2/aria2/releases/download/release-${versions['aria2c']}/aria2-${versions['aria2c']}-win-64bit-build1.zip`,
src: path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1.zip`),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1`, 'aria2c.exe')
],
binDest: [
path.join(binDir, 'aria2c-x86_64-pc-windows-msvc.exe')
]
},
cleanup: [
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1.zip`),
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1`)
]
},
{
name: 'aria2c-x86_64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/asdo92/aria2-static-builds/releases/download/v${versions['aria2c']}/aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`,
src: path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`),
dest: null,
archive: {
type: 'tar.bz2',
binSrc: [
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1`, 'aria2c')
],
binDest: [
path.join(binDir, 'aria2c-x86_64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`),
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1`)
]
},
{
name: 'aria2c-aarch64-unknown-linux-gnu',
platform: 'linux',
url: `https://github.com/aria2/aria2/releases/download/release-${versions['aria2c']}/aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`,
src: path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`),
dest: null,
archive: {
type: 'zip',
binSrc: [
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`, 'aria2c')
],
binDest: [
path.join(binDir, 'aria2c-aarch64-unknown-linux-gnu')
]
},
cleanup: [
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`),
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`)
]
}
]
}
function downloadAndProcess(bin) {
console.log(`=> Processing: ${bin.name}`);
console.log(`Downloading: ${bin.url}`);
if (platform === 'win32') {
execSync(`powershell -Command "Invoke-WebRequest -Uri '${bin.url}' -OutFile '${bin.src}'"`, { stdio: 'inherit' });
} else {
execSync(`curl -L "${bin.url}" -o "${bin.src}"`, { stdio: 'inherit' });
}
if (bin.archive) {
console.log(`Extracting: ${bin.src}`);
if (platform === 'win32' && bin.archive.type === 'zip') {
execSync(`powershell -Command "Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${bin.src}', '${downloadDir}')"`, { stdio: 'inherit' });
} else if (bin.archive.type === 'tar.bz2') {
execSync(`tar -xjf "${bin.src}" -C "${downloadDir}"`, { stdio: 'inherit' });
} else if (bin.archive.type === 'zip') {
execSync(`unzip -o "${bin.src}" -d "${downloadDir}"`, { stdio: 'inherit' });
} else {
execSync(`tar -xf "${bin.src}" -C "${downloadDir}"`, { stdio: 'inherit' });
}
bin.archive.binSrc.forEach((src, index) => {
const dest = bin.archive.binDest[index];
console.log(`Moving: "${src}" to "${dest}"`);
fs.copyFileSync(src, dest);
if (platform !== 'win32') {
fs.chmodSync(dest, 0o755);
}
});
} else if (bin.dest) {
bin.dest.forEach((dest) => {
console.log(`Moving: "${bin.src}" to "${dest}"`);
fs.copyFileSync(bin.src, dest);
if (platform !== 'win32') {
fs.chmodSync(dest, 0o755);
}
});
}
bin.cleanup.forEach((item) => {
if (fs.existsSync(item)) {
console.log(`Cleaning: "${item}"`);
const stats = fs.statSync(item);
if (stats.isDirectory()) {
fs.rmSync(item, { recursive: true, force: true });
} else {
fs.unlinkSync(item);
}
}
});
}
if (targetPlatform && !['win32', 'linux', 'darwin', 'all'].includes(targetPlatform)) {
console.error(`ERROR: Invalid platform specified: '${targetPlatform}'. Use one of: win32, linux, darwin, or all`);
process.exit(1);
}
if (targetBin && !binaries.hasOwnProperty(targetBin) && targetBin !== 'all') {
console.error(`ERROR: Invalid binary specified: '${targetBin}'. Use one of: ${Object.keys(binaries).join(', ')}, or all`);
process.exit(1);
}
const effectivePlatform = targetPlatform || platform;
const effectiveBin = targetBin || 'all';
console.log(`RUNNING: 📦 Binary Downloader (platform: ${effectivePlatform} | binary: ${effectiveBin})`);
Object.keys(binaries).forEach((binKey) => {
if (effectiveBin !== 'all' && binKey !== effectiveBin) {
return;
}
binaries[binKey].forEach((bin) => {
if (effectivePlatform !== 'all' && bin.platform !== effectivePlatform) {
return;
}
downloadAndProcess(bin);
});
});
console.log('✅ Downloads Completed');

View File

@@ -5,8 +5,9 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
console.log(`RUNNING: 🛠️ Build Script updateYtDlpBinary.js`);
console.log(`RUNNING: 🛠️ Build Script --> update-yt-dlp.js`);
// Get the platform triple from command line arguments
const platformTriple = process.argv[2];
@@ -17,7 +18,7 @@ if (!platformTriple) {
}
// Define the binaries directory
const binariesDir = path.join(__dirname, 'src-tauri', 'binaries');
const binariesDir = path.join(projectRoot, 'src-tauri', 'binaries');
// Construct the binary filename based on platform triple
let binaryName = `yt-dlp-${platformTriple}`;
@@ -50,7 +51,7 @@ execFile(binaryPath, ['--update-to', 'nightly'], (error, stdout, stderr) => {
if (stderr) console.error(stderr);
process.exit(1);
}
console.log(`Update successful for ${platformTriple}:`);
console.log(stdout);
});
});

2666
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
size 34391824

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
size 34391824

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f061f5be11e96c3764442b5339f91102316ac9a7eb8270f017e7912ed0384eba
size 18112457

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0aa0afe3d2b32c047b73083f3c8e56081d71bb33fe047357820d51d153d1d54f
size 34608400

View File

@@ -22,6 +22,9 @@
"fs:allow-app-write-recursive",
"updater:default",
"process:default",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"notification:default",
{
"identifier": "opener:allow-open-path",
"allow": [
@@ -39,4 +42,4 @@
]
}
]
}
}

View File

@@ -15,10 +15,45 @@
"args": true,
"sidecar": true
},
{
"name": "binaries/ffmpeg",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffprobe",
"args": true,
"sidecar": true
},
{
"name": "binaries/aria2c",
"args": true,
"sidecar": true
},
{
"name": "binaries/deno",
"args": true,
"sidecar": true
},
{
"name": "ffmpeg",
"cmd": "ffmpeg",
"args": true
},
{
"name": "aria2c",
"cmd": "aria2c",
"args": true
},
{
"name": "pkexec",
"cmd": "pkexec",
"args": true
},
{
"name": "powershell",
"cmd": "powershell",
"args": true
}
]
},
@@ -29,6 +64,36 @@
"name": "binaries/yt-dlp",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffmpeg",
"args": true,
"sidecar": true
},
{
"name": "binaries/ffprobe",
"args": true,
"sidecar": true
},
{
"name": "binaries/aria2c",
"args": true,
"sidecar": true
},
{
"name": "binaries/deno",
"args": true,
"sidecar": true
},
{
"name": "ffmpeg",
"cmd": "ffmpeg",
"args": true
},
{
"name": "aria2c",
"cmd": "aria2c",
"args": true
}
]
}
@@ -38,4 +103,4 @@
"macOS",
"linux"
]
}
}

View File

@@ -1,8 +1,8 @@
!macro NSIS_HOOK_POSTINSTALL
; Add Registry Keys for Chrome Native Messaging Host
WriteRegStr HKCU "Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\neodlp-msghost.json"
WriteRegStr HKCU "Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\chrome.json"
; Add Registry Keys for Firefox Native Messaging Host
WriteRegStr HKCU "Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\neodlp-msghost-moz.json"
WriteRegStr HKCU "Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\firefox.json"
; Add entry for automatic startup with Windows
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" "$\"$INSTDIR\neodlp.exe$\" --hidden"
!macroend
@@ -12,4 +12,4 @@
DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp"
DeleteRegKey HKCU "Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp"
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}"
!macroend
!macroend

View File

@@ -4,10 +4,10 @@
<DirectoryRef Id="TARGETDIR">
<Component Id="NeoDlpRegEntriesFragment" Guid="*">
<RegistryKey Root="HKLM" Key="Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Value="[INSTALLDIR]neodlp-msghost.json" KeyPath="no" />
<RegistryValue Type="string" Value="[INSTALLDIR]chrome.json" KeyPath="no" />
</RegistryKey>
<RegistryKey Root="HKLM" Key="Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Value="[INSTALLDIR]neodlp-msghost-moz.json" KeyPath="no" />
<RegistryValue Type="string" Value="[INSTALLDIR]firefox.json" KeyPath="no" />
</RegistryKey>
<RegistryKey Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Run">
<RegistryValue Name="NeoDLP" Type="string" Value="&quot;[INSTALLDIR]neodlp.exe&quot; --hidden" KeyPath="no" />
@@ -15,4 +15,4 @@
</Component>
</DirectoryRef>
</Fragment>
</Wix>
</Wix>

View File

@@ -12,6 +12,6 @@ license = "MIT"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "*"
futures-util = "0.3"
directories = "5.0"
directories = "6.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
size 79945800

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
size 79945800

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c49b5913c9a107120c86b401af95df7965003f7fc6dbb4436f1f03c8ba391e8b
size 127473664

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ee15e5145c9eb4775c193ab824c592d4ff3744bb7f283f8db29bd3c3c961589
size 79928672

View File

View File

@@ -174,6 +174,16 @@ fn get_config_file_path() -> Result<String, String> {
}
}
#[tauri::command]
fn get_current_app_path() -> Result<String, String> {
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
Ok(exe_path
.parent()
.ok_or("Failed to get parent directory")?
.to_string_lossy()
.into_owned())
}
#[tauri::command]
async fn update_config(
new_config: Config,
@@ -452,6 +462,7 @@ async fn pause_ongoing_downloads(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run() {
let _ = fix_path_env::fix();
let migrations = migrations::get_migrations();
let config = load_config();
let port = config.port;
@@ -485,6 +496,8 @@ pub async fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.manage(ImageCache(StdMutex::new(HashMap::new())))
.manage(websocket_state.clone())
.setup(move |app| {
@@ -586,6 +599,7 @@ pub async fn run() {
reset_config,
get_config_file_path,
restart_websocket_server,
get_current_app_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -68,5 +68,174 @@ pub fn get_migrations() -> Vec<Migration> {
);
",
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "add_columns_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_index INTEGER,
resolution TEXT,
ext TEXT,
abr REAL,
vbr REAL,
acodec TEXT,
vcodec TEXT,
dynamic_range TEXT,
process_id INTEGER,
status 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,
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, playlist_index, resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status, progress, total,
downloaded, speed, eta, filepath, filetype, filesize,
NULL, -- output_format
0, -- embed_metadata
0, -- embed_thumbnail
NULL, -- sponsorblock_remove
NULL, -- sponsorblock_mark
0, -- use_aria2
NULL, -- custom_command
NULL, -- queue_config
CURRENT_TIMESTAMP, -- created_at
CURRENT_TIMESTAMP -- updated_at
FROM downloads;
-- 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;
",
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",
"productName": "NeoDLP",
"mainBinaryName": "neodlp",
"version": "0.2.0",
"version": "0.4.0",
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "npm run dev",
@@ -36,6 +36,9 @@
]
},
"plugins": {
"sql": {
"preload": ["sqlite:database.db"]
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDM0I4ODcyODdGOTM4MDIKUldRQ09QbUhjb2c3UENGY1lFUVdTVWhucmJ4QzdGeW9sU3VHVFlGNWY5anZab2s4SU1rMWFsekMK",
"endpoints": [

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-unknown-linux-gnu && npm run build",
"beforeDevCommand": "cargo build --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,32 +36,33 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/aria2c",
"binaries/deno"
],
"resources": {
"resources/binaries/ffmpeg-x86_64-unknown-linux-gnu": "binaries/ffmpeg-x86_64"
},
"linux": {
"deb": {
"depends": ["ffmpeg"],
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
},
"rpm": {
"epoch": 0,
"release": "1",
"depends": ["ffmpeg"],
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "cargo build --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "NeoDLP",
"width": 1067,
"height": 605,
"visible": false
}
],
"security": {
"csp": null,
"capabilities": [
"default",
"shell-scope"
]
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "appimage"],
"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/aria2c",
"binaries/deno"
],
"linux": {
"deb": {
"depends": ["ffmpeg"],
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
},
"rpm": {
"epoch": 0,
"release": "1",
"depends": ["ffmpeg"],
"files": {
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
}
},
"appimage": {
"files": {
"/usr/bin/ffmpeg": "./binaries/ffmpeg-x86_64-unknown-linux-gnu",
"/usr/bin/ffprobe": "./binaries/ffprobe-x86_64-unknown-linux-gnu"
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js aarch64-apple-darwin && npm run build",
"beforeDevCommand": "cargo build --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,11 +36,13 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/deno"
],
"resources": {
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/binaries/ffmpeg-aarch64-apple-darwin": "binaries/ffmpeg-aarch64",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"
@@ -49,4 +51,4 @@
"providerShortName": "neosubhamoy"
}
}
}
}

View File

@@ -1,8 +1,8 @@
{
"identifier": "com.neosubhamoy.neodlp",
"build": {
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-apple-darwin && npm run build",
"beforeDevCommand": "cargo build --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
"beforeBuildCommand": "cargo build --release --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,11 +36,13 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/deno"
],
"resources": {
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
"resources/binaries/ffmpeg-x86_64-apple-darwin": "binaries/ffmpeg-x86_64",
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"
@@ -49,4 +51,4 @@
"providerShortName": "neosubhamoy"
}
}
}
}

View File

@@ -2,7 +2,7 @@
"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 && node updateYtDlpBinary.js x86_64-pc-windows-msvc && npm run build",
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@@ -36,13 +36,16 @@
"icons/icon.ico"
],
"externalBin": [
"binaries/yt-dlp"
"binaries/yt-dlp",
"binaries/ffmpeg",
"binaries/ffprobe",
"binaries/aria2c",
"binaries/deno"
],
"resources": {
"resources/binaries/ffmpeg-x86_64-pc-windows-msvc.exe": "binaries/ffmpeg-x86_64.exe",
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
"resources/msghost-manifest/windows/chrome.json": "neodlp-msghost.json",
"resources/msghost-manifest/windows/firefox.json": "neodlp-msghost-moz.json"
"resources/msghost-manifest/windows/chrome.json": "chrome.json",
"resources/msghost-manifest/windows/firefox.json": "firefox.json"
},
"windows": {
"wix": {
@@ -54,4 +57,4 @@
}
}
}
}
}

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

@@ -57,8 +57,8 @@ const FormatSelectionGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"relative w-full rounded-lg border-2 border-border bg-card px-3 py-2 shadow-sm transition-all",
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
"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]:bg-primary/10",
"hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50",
className
@@ -79,4 +79,4 @@ const FormatSelectionGroupItem = React.forwardRef<
})
FormatSelectionGroupItem.displayName = "FormatSelectionGroupItem"
export { FormatSelectionGroup, FormatSelectionGroupItem }
export { FormatSelectionGroup, FormatSelectionGroupItem }

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
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

@@ -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

@@ -35,8 +35,8 @@ const PlaylistSelectionGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"relative w-full rounded-lg border-2 border-border bg-card p-2 shadow-sm transition-all",
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
"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]:bg-primary/10",
"hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50",
className
@@ -44,28 +44,28 @@ const PlaylistSelectionGroupItem = React.forwardRef<
{...props}
>
<div className="flex gap-2 w-full relative">
<div className="w-[7rem] xl:w-[10rem]">
<AspectRatio
ratio={16 / 9}
<div className="w-28 xl:w-40">
<AspectRatio
ratio={16 / 9}
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"
)}
>
<ProxyImage
src={video.thumbnail}
alt="thumbnail"
<ProxyImage
src={video.thumbnail}
alt="thumbnail"
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"
)}
)}
/>
</AspectRatio>
</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>
<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">
<span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/>
@@ -79,4 +79,4 @@ const PlaylistSelectionGroupItem = React.forwardRef<
})
PlaylistSelectionGroupItem.displayName = "PlaylistSelectionGroupItem"
export { PlaylistSelectionGroup, PlaylistSelectionGroupItem }
export { PlaylistSelectionGroup, PlaylistSelectionGroupItem }

View File

@@ -3,7 +3,6 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
import { Checkbox } from "@/components/ui/checkbox";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { ProxyImage } from "@/components/custom/proxyImage";
import { Clock } from "lucide-react";
@@ -11,7 +10,6 @@ import clsx from "clsx";
import { formatDurationString } from "@/utils";
import { RawVideoInfo } from "@/types/video";
// Create a context to share toggle group props
const PlaylistToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
>({
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
toggleType: "multiple",
});
// Helper type for the PlaylistToggleGroup
type PlaylistToggleGroupProps =
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
type PlaylistToggleGroupProps =
| (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"> &
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
// Main PlaylistToggleGroup component with proper type handling
export const PlaylistToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
PlaylistToggleGroupProps
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
// Pass props based on the type
if (type === "single") {
return (
<ToggleGroupPrimitive.Root
@@ -47,7 +42,7 @@ export const PlaylistToggleGroup = React.forwardRef<
</ToggleGroupPrimitive.Root>
);
}
return (
<ToggleGroupPrimitive.Root
ref={ref}
@@ -63,106 +58,48 @@ export const PlaylistToggleGroup = React.forwardRef<
});
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
// Rest of your component remains the same
// PlaylistToggleGroupItem component with checkbox and item layout
export const PlaylistToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> & {
video: RawVideoInfo;
}
>(({ 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 (
<ToggleGroupPrimitive.Item
ref={(el) => {
// Handle both our ref and the forwarded ref
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
setItemElement(el);
}}
ref={ref}
className={cn(
"flex w-full p-2 rounded-md transition-colors border-2 border-border",
"hover:bg-muted/50 data-[state=on]:bg-muted/70",
"flex w-full p-2 rounded-lg transition-colors border-2 border-border",
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
"data-[state=on]:border-primary",
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
value={value}
{...props}
>
<div className="flex gap-2 w-full relative">
<div className="absolute top-2 left-2 z-10">
<Checkbox
checked={checked}
onClick={handleCheckboxClick}
className={cn(
"transition-opacity",
isHovered || checked ? "opacity-100" : "opacity-0"
)}
/>
</div>
<div className="w-[7rem] xl:w-[10rem]">
<AspectRatio
ratio={16 / 9}
<div className="w-28 xl:w-40">
<AspectRatio
ratio={16 / 9}
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"
)}
>
<ProxyImage
src={video.thumbnail}
alt="thumbnail"
<ProxyImage
src={video.thumbnail}
alt="thumbnail"
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"
)}
)}
/>
</AspectRatio>
</div>
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3>
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
<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>
<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">
<span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/>
@@ -174,4 +111,4 @@ export const PlaylistToggleGroupItem = React.forwardRef<
</ToggleGroupPrimitive.Item>
);
});
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";

View File

@@ -23,7 +23,7 @@ export const SlidingButton = ({
return (
<Tag
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`,
className
)}
@@ -41,7 +41,7 @@ export const SlidingButton = ({
</span>
<div
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`
)}
>
@@ -49,4 +49,4 @@ export const SlidingButton = ({
</div>
</Tag>
);
};
};

View File

@@ -2,20 +2,21 @@ import { useLocation } from "react-router-dom";
import { isActive } from "@/utils";
import { config } from "@/config";
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() {
const location = useLocation();
const isSettingsPage = isActive("/settings", location.pathname, true);
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
return (
<>
{isSettingsPage ? (
<div className="flex items-center justify-between p-4 border-t border-border">
<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-xs text-muted-foreground">Made with by <a href={config.appAuthorUrl} target="_blank">{config.appAuthor}</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">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 className="flex items-center gap-2">
<a href={config.appHomepage} target="_blank" className="text-sm text-muted-foreground hover:text-foreground" title="Homepage">

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

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

View File

@@ -1,30 +1,88 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getRouteName } from "@/utils";
// import { Button } from "@/components/ui/button";
// import { Terminal } from "lucide-react";
// import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { BrushCleaning, Check, Copy, Terminal } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useLogger } from "@/helpers/use-logger";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
export default function Navbar() {
const [copied, setCopied] = useState(false);
const location = useLocation();
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 (
<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">
<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="flex justify-center">
<SidebarTrigger />
<h1 className="text-lg text-primary font-semibold ml-4">{getRouteName(location.pathname)}</h1>
<h1 className="text-lg font-semibold ml-4">{getRouteName(location.pathname)}</h1>
</div>
<div className="flex justify-center items-center">
{/* <Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon">
<Terminal />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Logs</p>
</TooltipContent>
</Tooltip> */}
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Terminal />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<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
className="transition-all duration-300"
disabled={logs.length === 0}
onClick={() => handleCopyLogs()}
>
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
Copy Logs
</Button>
</DialogFooter>
</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();
console.log(selectedAudioFormats);
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,406 @@
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";
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
direction="horizontal"
className="w-full"
onLayout={(sizes) => setPlaylistPanelSizes(sizes)}
>
<ResizablePanel
defaultSize={playlistPanelSizes[0]}
>
<PlaylistPreviewSelection videoMetadata={videoMetadata} />
</ResizablePanel>
<ResizableHandle />
<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,383 @@
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";
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
direction="horizontal"
className="w-full"
onLayout={(sizes) => setVideoPanelSizes(sizes)}
>
<ResizablePanel
defaultSize={videoPanelSizes[0]}
>
<VideoPreview videoMetadata={videoMetadata} />
</ResizablePanel>
<ResizableHandle />
<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 />
</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()}</>
)} {
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 />
</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,295 @@
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 { Input } from "@/components/ui/input";
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";
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>
<Input
className="focus-visible:ring-0"
placeholder="Enter port number"
{...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 { Link, useLocation } from "react-router-dom";
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 { RoutesObj } from "@/types/route";
import { AllRoutes } from "@/routes";
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { Badge } from "@/components/ui/badge";
import { useEffect, useState } from "react";
@@ -14,211 +15,226 @@ import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Progress } from "@/components/ui/progress";
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() {
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
const ongoingDownloads = downloadStates.filter(state =>
['starting', 'downloading', 'queued'].includes(state.download_status)
);
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const isFetchingAppVersion = useSettingsPageStatesStore(state => state.isFetchingAppVersion);
const appUpdate = useSettingsPageStatesStore(state => state.appUpdate);
const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp);
const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress);
const location = useLocation();
const { open } = useSidebar();
const { downloadAndInstallAppUpdate } = useAppUpdater();
const [showBadge, setShowBadge] = useState(false);
const [showUpdateCard, setShowUpdateCard] = useState(false);
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
const ongoingDownloads = downloadStates.filter(state =>
['starting', 'downloading', 'queued'].includes(state.download_status)
);
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
const isFetchingAppVersion = useSettingsPageStatesStore(state => state.isFetchingAppVersion);
const appUpdate = useSettingsPageStatesStore(state => state.appUpdate);
const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp);
const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress);
const currentPlatform = platform();
const location = useLocation();
const { open } = useSidebar();
const { downloadAndInstallAppUpdate } = useAppUpdater();
const [showBadge, setShowBadge] = useState(false);
const [showUpdateCard, setShowUpdateCard] = useState(false);
const [isNativeLinuxApp, setIsNativeLinuxApp] = useState(false);
const topItems: Array<RoutesObj> = [
{
title: "Downloader",
url: "/",
icon: Download,
},
{
title: "Library",
url: "/library",
icon: SquarePlay,
}
];
const topItems: Array<RoutesObj> = [
AllRoutes[0], // Downloader
AllRoutes[1], // Library
];
const bottomItems: Array<RoutesObj> = [
{
title: "Settings",
url: "/settings",
icon: Settings,
}
];
const bottomItems: Array<RoutesObj> = [
AllRoutes[2], // Settings
];
useEffect(() => {
let timeout: NodeJS.Timeout;
if (open) {
timeout = setTimeout(() => {
setShowBadge(true);
setShowUpdateCard(true);
}, 300);
} else {
setShowBadge(false);
setShowUpdateCard(false);
}
return () => {
clearTimeout(timeout);
};
}, [open]);
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
<NeoDlpLogo className="size-full rounded-lg border border-border" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Neo Downloader Plus</span>
<span className="truncate text-xs">{isFetchingAppVersion ? 'Loading...' : `v${appVersion}`}</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* <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 />
<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>
useEffect(() => {
let timeout: NodeJS.Timeout;
if (open) {
timeout = setTimeout(() => {
setShowBadge(true);
setShowUpdateCard(true);
}, 300);
} else {
setShowBadge(false);
setShowUpdateCard(false);
}
return () => {
clearTimeout(timeout);
};
}, [open]);
useEffect(() => {
(async () => {
if (currentPlatform === 'linux') {
const neoDlpExists = await fs.exists('/usr/bin/neodlp');
setIsNativeLinuxApp(neoDlpExists);
}
})();
}, [currentPlatform]);
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
<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)]" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Neo Downloader Plus</span>
<span className="truncate text-xs">{isFetchingAppVersion ? 'Loading...' : `v${appVersion}`}</span>
</div>
</a>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.title}</p>
</TooltipContent>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* <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" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Update Available <br></br>(Expand sidebar to view update)</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 />
<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">{ongoingDownloads.length}</Badge>
)}
{appUpdate && open && showUpdateCard && (
<Card className="gap-4 py-0">
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm">Update Available (v{appUpdate?.version || '0.0.0'})</CardTitle>
<CardDescription>
A newer version of {config.appName} is available. Please update to the latest version for the best experience.
</CardDescription>
<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>
</CardHeader>
<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>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
{appUpdate && !open && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost">
<CircleArrowUp className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Update Available <br></br>(Expand sidebar to view update)</p>
</TooltipContent>
</Tooltip>
)}
{appUpdate && open && showUpdateCard && (
<Card className="gap-4 py-0">
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm">Update Available (v{appUpdate.version})</CardTitle>
<CardDescription>
A newer version of {config.appName} is available. Please update to the latest version for the best experience.
</CardDescription>
<a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/neosubhamoy/neodlp/releases/tag/v${appUpdate.version}`} target="_blank"> Read Changelog</a>
</CardHeader>
<CardContent className="grid gap-2.5 p-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full"
size="sm"
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
onClick={() => downloadAndInstallAppUpdate(appUpdate)}
>
Download and Install
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
<AlertDialogDescription className="text-center">Updating {config.appName} to v{appUpdate.version}, 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>
)
}
</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 className="stroke-primary" />
<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 className="stroke-primary" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,64 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
const Accordion = AccordionPrimitive.Root
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}
/>
)
}
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
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"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

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

View File

@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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: {
variant: {
default: "bg-card text-card-foreground",
default: "bg-background text-foreground",
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: {
@@ -19,48 +19,41 @@ const alertVariants = cva(
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

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

View File

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

View File

@@ -1,22 +1,20 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
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: {
variant: {
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:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
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",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
@@ -25,21 +23,13 @@ const badgeVariants = cva(
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}

View File

@@ -4,99 +4,105 @@ 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} />
}
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
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}
/>
)
}
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
})
BreadcrumbLink.displayName = "BreadcrumbLink"
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}
/>
)
}
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
function BreadcrumbSeparator({
const 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>
)
}
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
function BreadcrumbEllipsis({
const 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>
)
}
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

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

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
@@ -27,7 +29,7 @@ function Calendar({
<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",
"bg-background group/calendar p-3 [--cell-size:2rem] [[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
@@ -41,69 +43,72 @@ function Calendar({
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
"flex h-[--cell-size] w-full items-center justify-center 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",
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
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",
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
dropdown: cn(
"bg-popover 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",
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>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",
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
"text-muted-foreground select-none text-[0.8rem]",
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",
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
@@ -154,7 +159,7 @@ function Calendar({
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
@@ -196,7 +201,7 @@ function CalendarDayButton({
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",
"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 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}

View File

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

View File

@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
@@ -42,106 +40,124 @@ function useCarousel() {
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
ref
) => {
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()
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
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}
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,
}}
>
{children}
</div>
</CarouselContext.Provider>
)
}
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
@@ -151,16 +167,20 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
/>
</div>
)
}
})
CarouselContent.displayName = "CarouselContent"
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
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",
@@ -169,25 +189,24 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
{...props}
/>
)
}
})
CarouselItem.displayName = "CarouselItem"
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
@@ -195,29 +214,28 @@ function CarouselPrevious({
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
}
})
CarouselPrevious.displayName = "CarouselPrevious"
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
@@ -225,11 +243,12 @@ function CarouselNext({
onClick={scrollNext}
{...props}
>
<ArrowRight />
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
}
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,

View File

@@ -1,5 +1,13 @@
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"
@@ -20,6 +28,36 @@ 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() {
@@ -82,17 +120,17 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
__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")}
}
`
${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"),
}}
@@ -105,25 +143,18 @@ const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
label,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
labelClassName,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
}: CustomTooltipProps) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
@@ -134,10 +165,14 @@ function ChartTooltipContent({
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
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 (
@@ -254,11 +289,7 @@ function ChartLegendContent({
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
}: ChartLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {

View File

@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
@@ -21,7 +19,7 @@ function Checkbox({
>
<CheckboxPrimitive.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" />
</CheckboxPrimitive.Indicator>
@@ -30,3 +28,4 @@ function Checkbox({
}
export { Checkbox }

View File

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

View File

@@ -1,58 +1,33 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Dialog, DialogContent } 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}
/>
)
}
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
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
}) {
const CommandDialog = ({ children, ...props }: DialogProps) => {
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">
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[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>
@@ -60,116 +35,110 @@ function CommandDialog({
)
}
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"
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
</div>
))
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
CommandInput.displayName = CommandPrimitive.Input.displayName
function CommandGroup({
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
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">) {
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,

View File

@@ -1,237 +1,183 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
const ContextMenu = ContextMenuPrimitive.Root
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
const ContextMenuGroup = ContextMenuPrimitive.Group
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
const ContextMenuPortal = ContextMenuPrimitive.Portal
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
const ContextMenuSub = ContextMenuPrimitive.Sub
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
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}
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
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",
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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-context-menu-content-transform-origin]",
className
)}
{...props}
/>
)
}
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
function ContextMenuContent({
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
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">) {
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,

View File

@@ -1,141 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
const Dialog = DialogPrimitive.Root
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
const DialogTrigger = DialogPrimitive.Trigger
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
const DialogPortal = DialogPrimitive.Portal
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogClose = DialogPrimitive.Close
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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(
"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
)}
{...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({
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({
const DialogHeader = ({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
function DialogDescription({
const DialogFooter = ({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...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 {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogFooter,
DialogTitle,
DialogTrigger,
DialogDescription,
}

View File

@@ -3,121 +3,104 @@ import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
const DrawerTrigger = DrawerPrimitive.Trigger
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
const DrawerPortal = DrawerPrimitive.Portal
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
const DrawerClose = DrawerPrimitive.Close
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
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 inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
/>
)
}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
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({
const DrawerHeader = ({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
function DrawerDescription({
const DrawerFooter = ({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,

View File

@@ -2,256 +2,200 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
const DropdownMenu = DropdownMenuPrimitive.Root
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group
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>
)
}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
const DropdownMenuSub = DropdownMenuPrimitive.Sub
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}
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
)
}
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
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>
)
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
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>
)
}
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
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}
/>
)
}
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
function DropdownMenuSeparator({
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
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">) {
}: React.HTMLAttributes<HTMLSpanElement>) => {
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
)}
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

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,
}

244
src/components/ui/field.tsx Normal file
View File

@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field data-[invalid=true]:text-destructive flex w-full gap-3",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start",
],
responsive: [
"@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

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

View File

@@ -3,40 +3,25 @@ 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} />
}
const HoverCard = HoverCardPrimitive.Root
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
const HoverCardTrigger = HoverCardPrimitive.Trigger
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>
)
}
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -1,57 +1,46 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { Minus } 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}
/>
)
}
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
ref={ref}
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]",
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
@@ -59,19 +48,22 @@ function InputOTPSlot({
{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 className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}
})
InputOTPSlot.displayName = "InputOTPSlot"
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

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

193
src/components/ui/item.tsx Normal file
View File

@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4 ",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-balance text-sm font-normal leading-normal",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

28
src/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -2,23 +2,25 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"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}
/>
)
}
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -1,211 +1,31 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { Check, ChevronRight, Circle } 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} />
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
return <MenubarPrimitive.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}
/>
)
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
@@ -214,61 +34,221 @@ function MenubarSub({
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>
)
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
function MenubarSubContent({
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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-menubar-content-transform-origin]",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 origin-[--radix-menubar-content-transform-origin]",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
<span
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",
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -1,161 +1,122 @@
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 { ChevronDown } 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>
)
}
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
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}
/>
)
}
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
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>
)
}
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full 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 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
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",
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
)
}
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
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>
)
}
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
@@ -164,5 +125,4 @@ export {
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

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