├── .env ├── .env.development ├── .env.production ├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── README_EN.md ├── components.json ├── doc └── NasInstall.md ├── docker-compose.yml ├── download.sh ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── pic ├── demo.png ├── demo2.png ├── demo2_en.png ├── demo3.png ├── demo3_en.png ├── demo_dark.png ├── demo_dark_en.png ├── demo_en.png ├── fnos_docker.png ├── fnos_file.png └── fnos_file_struct.png ├── public └── favicon.svg ├── scripts └── synology.sh ├── src ├── App.css ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── About.tsx │ ├── AppSidebar.tsx │ ├── FileUpload.tsx │ ├── ModeToggle.tsx │ ├── NavMain.tsx │ ├── SectionCards.tsx │ ├── SiteHeader.tsx │ ├── ThemeProvider.tsx │ ├── ToastLayout.tsx │ ├── TorrentDrawer.tsx │ ├── TorrentManager.tsx │ ├── TorrentTable.tsx │ ├── TorrentToolbar.tsx │ ├── dialog │ │ ├── AddDialog.tsx │ │ ├── DeleteDialog.tsx │ │ ├── EditDialog.tsx │ │ └── LabelEdit.tsx │ ├── settings │ │ ├── NumbericInput.tsx │ │ └── SessionSetting.tsx │ ├── table │ │ ├── ActionButton.tsx │ │ ├── ColumnFilter.tsx │ │ ├── ColumnView.tsx │ │ ├── SortableHeader.tsx │ │ ├── TorrentColumns.tsx │ │ └── TorrentStatus.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── navigation-menu.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx ├── constants │ └── storage.ts ├── hooks │ ├── use-mobile.ts │ ├── useDragAndDropUpload.ts │ ├── useTorrentActions.ts │ └── useTorrentTable.ts ├── index.css ├── lib │ ├── dayjs.ts │ ├── i18n.ts │ ├── rowAction.ts │ ├── torrentLabel.ts │ ├── transmissionClient.ts │ ├── types.ts │ └── utils.ts ├── locales │ ├── en.json │ └── zh-CN.json ├── main.tsx ├── schemas │ └── torrentSchema.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/.env -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=http://127.0.0.1:3001/rpc -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=/transmission/rpc -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - github-fix 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '23.11.0' 22 | cache: 'npm' 23 | 24 | - name: Install Dependencies 25 | run: npm install 26 | 27 | - name: Build Project 28 | env: 29 | VITE_APP_VERSION: ${{ github.ref_name }} 30 | run: npm run build 31 | 32 | - name: Zip release folder 33 | run: cd dist && zip -r ../release.zip . 34 | 35 | - name: Upload to GitHub Release 36 | uses: softprops/action-gh-release@v2 37 | with: 38 | files: release.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 折纸飞机 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Transmission Next UI

2 |

一个Transmission的第三方Web UI,使用shadcn/ui和Vite构建。

3 | 4 |

5 | GitHub License 6 | GitHub Release 7 | GitHub Workflow Status 8 |

9 | 10 | --- 11 | 12 | 简体中文 | [English](README_EN.md) 13 | 14 | 15 | ## 预览 16 | 17 | ![demo.png](pic/demo.png) 18 | ![demo_dark.png](pic/demo_dark.png) 19 | ![demo2.png](pic/demo2.png) 20 | ![demo3.png](pic/demo3.png) 21 | 22 | ## 功能 23 | 24 | - 现代化 UI 设计 25 | - 响应式布局,适配所有设备 26 | - 适配 Transmission 4.0+ 版本 27 | - [x] 种子管理,信息查看 28 | - [x] 配置设置 29 | - [x] 拖拽/粘贴添加种子 30 | - [x] 深色模式支持 31 | - [x] Tracker 过滤 32 | - [x] 支持种子标签 33 | 34 | ⚠️本项目为早期版本,尚未经过完整测试,正式使用前请自行验证其功能是否符合预期。 35 | 36 | ## 快速开始 37 | 38 | 可以通过三种方式部署 Transmission Next UI,另外提供对于[群辉](doc/NasInstall.md#群辉)和[飞牛OS](doc/NasInstall.md#飞牛OS)的安装指引: 39 | 40 | ### 1. 一键安装 41 | 42 | > 依赖环境:`docker`、`docker-compose` 和 `curl` 43 | 44 | 初次安装或升级到最新版本,运行以下命令: 45 | 46 | ```bash 47 | curl -fsSL https://raw.githubusercontent.com/hisproc/transmission-next-ui/main/download.sh | bash 48 | ``` 49 | 50 | 执行后将在当前目录生成一个 `docker-compose.yml` 文件。 51 | 52 | 编辑该文件来自定义 Transmission 的用户名、密码和时区: 53 | 54 | ```yaml 55 | environment: 56 | - USER=your-username 57 | - PASS=your-password 58 | - TZ=Asia/Shanghai 59 | ``` 60 | 61 | 然后通过以下命令启动或停止服务: 62 | 63 | ```bash 64 | docker-compose up -d # 后台启动 65 | docker-compose down # 停止并移除容器 66 | ``` 67 | 68 | 默认情况下容器使用 `network_mode: host` 网络模式,更适合 Linux 系统。 69 | **注意:** macOS 不支持 `host` 网络模式,此时请手动改为端口映射(如 `9091:9091`),并在 `docker-compose.yml` 中进行相应修改。 70 | 71 | ### 2. 手动安装 72 | 73 | 1. 打开 [Releases](https://github.com/hisproc/transmission-next-ui/releases) 页面 74 | 2. 下载最新或稳定版本(如 `transmission-next-ui-v1.0.0.zip`) 75 | 3. 解压并将其中的内容复制到 Transmission 的 Web 目录(如 `transmission/web/src`) 76 | 77 | ### 3. 源代码打包 78 | 79 | ```bash 80 | git clone git@github.com:hisproc/transmission-next-ui.git 81 | cd transmission-next-ui 82 | npm install 83 | npm run build 84 | ``` 85 | 86 | 构建完成后,将 `dist/` 目录下的所有内容复制到 Transmission 的 Web 目录下即可。 87 | 88 | ## 许可证 89 | 90 | 本项目采用 [MIT 许可证](LICENSE) 进行授权。 91 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |

Transmission Next UI

2 |

A third-party modern web-based frontend for Transmission, offering a sleek and responsive UI for managing your torrents with ease, built using shadcn/ui and Vite.

3 | 4 | --- 5 | 6 | English | [简体中文](README.md) 7 | 8 | ## Preview 9 | 10 | ![demo.png](pic/demo_en.png) 11 | ![demo_dark.png](pic/demo_dark_en.png) 12 | ![demo2.png](pic/demo2_en.png) 13 | ![demo3.png](pic/demo3_en.png) 14 | 15 | ## Features 16 | 17 | - Modern UI Design 18 | - Responsive Layout, Perfect for All Devices 19 | - Fast Loading Experience with Next.js 20 | - [x] Torrent Management and Information Viewing 21 | - [x] Configuration Settings 22 | - [x] Drag-and-Drop/Paste to Add Torrents 23 | - [x] Dark Mode Support 24 | - [x] Tracker Filter 25 | - [x] Torrent Label Support 26 | 27 | ⚠️**Notice**: This is an early version of the project. It has not been fully tested yet. Please verify its functionality before using it in production. 28 | 29 | ## Quick Start 30 | 31 | You can deploy Transmission Next UI in three different ways: 32 | 33 | ### 1. Easy Install 34 | 35 | > Requires: `docker`, `docker-compose`, and `curl` 36 | 37 | To install web UI or upgrade to the latest version, run the following command: 38 | 39 | ```bash 40 | curl -fsSL https://raw.githubusercontent.com/hisproc/transmission-next-ui/main/download.sh | bash 41 | ``` 42 | 43 | This will download the latest release and create a `docker-compose.yml` file in your current directory. 44 | 45 | You can now modify the `docker-compose.yml` file to set your own **Transmission username**, **password**, and **timezone**: 46 | 47 | ```yaml 48 | environment: 49 | - USER=your-username 50 | - PASS=your-password 51 | - TZ=Asia/Shanghai 52 | ``` 53 | 54 | Then use the following commands to start or stop the service: 55 | 56 | ```bash 57 | docker-compose up -d # start in background 58 | docker-compose down # stop and remove the container 59 | ``` 60 | 61 | By default, the container runs using `network_mode: host` for better connectivity, which is ideal for Linux environments. If you are on **macOS**, `host` mode is not supported — you will need to manually switch to **port mapping** (e.g., `9091:9091`) in the `docker-compose.yml` file. You can adjust this behavior in the `docker-compose.yml` according to your network setup. 62 | 63 | ### 2. Manual Install 64 | 65 | 1. Go to the [Releases](https://github.com/hisproc/transmission-next-ui/releases) page 66 | 2. Download the latest or stable release (e.g. `transmission-next-ui-v1.0.0.zip`) 67 | 3. Extract it and copy the contents to your transmission web directory (e.g. transmission/web/src) 68 | 69 | 70 | ### 3. Build from Source 71 | 72 | ```bash 73 | git clone git@github.com:hisproc/transmission-next-ui.git 74 | cd transmission-next-ui 75 | npm install 76 | npm run build 77 | ``` 78 | 79 | Then, copy the contents of the `dist/` folder to your transmission web directory. 80 | 81 | ## License 82 | 83 | This project is licensed under the [MIT License](LICENSE). 84 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /doc/NasInstall.md: -------------------------------------------------------------------------------- 1 | ## 群辉 2 | 3 | 使用**群辉**套件,可参考以下脚本,其中使用的套件来源为矿神群晖SPK,首先从[Release](https://github.com/hisproc/transmission-next-ui/releases)下载最新版本的压缩包`release.zip`,并下载[synology.sh](scripts/synology.sh)脚本到同一目录下,执行以下命令,脚本将自动解压并尝试解压到目标路径: 4 | 5 | ```bash 6 | chmod +x synology.sh 7 | sudo bash synology.sh 8 | ``` 9 | 10 | 输出 `Deployment complete` 即表示安装完成,旧的web目录内容会备份到当前执行目录下,文件为`backup.zip`,如果需要恢复到安装前的状态,请重命名`backup.zip`为`release.zip`,并重新执行上述命令。 11 | 12 | 更新时只需要再次执行脚本即可。 13 | 14 | ## 飞牛OS 15 | 16 | 飞牛OS自带了Docker环境,首先确定你要安装的路径,例如先创建一个文件夹,这里以transmission为例,获取文件夹的路径,通过SSH登入飞牛OS,执行脚本: 17 | 18 | ![fnos_file.png](../pic/fnos_file.png) 19 | 20 | ```bash 21 | cd /vol1/1000/transmission # 此处替换为你要安装的路径 22 | sudo curl -fsSL https://raw.githubusercontent.com/hisproc/transmission-next-ui/main/download.sh | bash 23 | ``` 24 | 25 | 执行后将在当前目录如下结构: 26 | 27 | ![fnos_file_struct.png](../pic/fnos_file_struct.png) 28 | 29 | 最后使用飞牛自带的Docker compose服务启动即可,默认用户名密码为`transmission:transmission`,可以直接在窗口下方的配置中修改: 30 | 31 | ![fnos_docker.png](../pic/fnos_docker.png) 32 | 33 | 启动后访问`http://IP:9091`即可访问。更新时只需要在transmission目录下重新执行一次脚本即可,无需重启docker-compose服务。 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # support older version docker-compose 2 | version: '3.3' 3 | services: 4 | transmission: 5 | image: linuxserver/transmission:4.0.0 6 | container_name: transmission 7 | environment: 8 | - PUID=0 9 | - PGID=0 10 | - TZ=Asia/Shanghai 11 | - TRANSMISSION_WEB_HOME=/src 12 | - USER=transmission 13 | - PASS=transmission 14 | volumes: 15 | - ./web/src:/src 16 | - ./config:/config 17 | - ./downloads:/downloads 18 | - ./watch:/watch 19 | # work on windows or linux 20 | network_mode: "host" 21 | # work on mac 22 | # ports: 23 | # - 9091:9091 24 | # - 51413:51413 25 | # - 51413:51413/udp 26 | 27 | restart: unless-stopped 28 | -------------------------------------------------------------------------------- /download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ZIP_URL="https://github.com/hisproc/transmission-next-ui/releases/latest/download/release.zip" 4 | DOCKER_COMPOSE_URL="https://raw.githubusercontent.com/hisproc/transmission-next-ui/main/docker-compose.yml" 5 | DEST_DIR="./web/src" 6 | TMP_ZIP="tmp.zip" 7 | 8 | mkdir -p "$DEST_DIR" 9 | 10 | curl -L -o "$TMP_ZIP" "$ZIP_URL" 11 | 12 | unzip -o "$TMP_ZIP" -d "$DEST_DIR" 13 | 14 | rm "$TMP_ZIP" 15 | 16 | if [ ! -f "docker-compose.yml" ]; then 17 | curl -L -o "docker-compose.yml" "$DOCKER_COMPOSE_URL" 18 | else 19 | echo "docker-compose.yml already exists, skipping download." 20 | fi -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Transmission Next UI 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transmission-next-ui", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@dnd-kit/core": "^6.3.1", 14 | "@dnd-kit/modifiers": "^9.0.0", 15 | "@dnd-kit/sortable": "^10.0.0", 16 | "@dnd-kit/utilities": "^3.2.2", 17 | "@radix-ui/react-alert-dialog": "^1.1.6", 18 | "@radix-ui/react-avatar": "^1.1.3", 19 | "@radix-ui/react-checkbox": "^1.1.4", 20 | "@radix-ui/react-context-menu": "^2.2.6", 21 | "@radix-ui/react-dialog": "^1.1.6", 22 | "@radix-ui/react-dropdown-menu": "^2.1.6", 23 | "@radix-ui/react-label": "^2.1.2", 24 | "@radix-ui/react-navigation-menu": "^1.2.5", 25 | "@radix-ui/react-popover": "^1.1.11", 26 | "@radix-ui/react-progress": "^1.1.2", 27 | "@radix-ui/react-select": "^2.1.6", 28 | "@radix-ui/react-separator": "^1.1.2", 29 | "@radix-ui/react-slot": "^1.1.2", 30 | "@radix-ui/react-switch": "^1.1.3", 31 | "@radix-ui/react-tabs": "^1.1.3", 32 | "@radix-ui/react-toggle": "^1.1.2", 33 | "@radix-ui/react-toggle-group": "^1.1.2", 34 | "@radix-ui/react-tooltip": "^1.1.8", 35 | "@tabler/icons-react": "^3.31.0", 36 | "@tailwindcss/vite": "^4.0.15", 37 | "@tanstack/react-query": "^5.69.0", 38 | "@tanstack/react-table": "^8.21.2", 39 | "axios": "^1.8.4", 40 | "class-variance-authority": "^0.7.1", 41 | "clsx": "^2.1.1", 42 | "cmdk": "^1.1.1", 43 | "dayjs": "^1.11.13", 44 | "filesize": "^10.1.6", 45 | "i18next": "^24.2.3", 46 | "i18next-browser-languagedetector": "^8.0.4", 47 | "lucide-react": "^0.483.0", 48 | "next-themes": "^0.4.6", 49 | "react": "^19.0.0", 50 | "react-dom": "^19.0.0", 51 | "react-i18next": "^15.4.1", 52 | "react-icons": "^5.5.0", 53 | "react-router-dom": "^7.4.0", 54 | "recharts": "^2.15.1", 55 | "sonner": "^2.0.2", 56 | "tailwind-merge": "^3.0.2", 57 | "tw-animate-css": "^1.2.4", 58 | "vaul": "^1.1.2", 59 | "zod": "^3.24.2" 60 | }, 61 | "devDependencies": { 62 | "@eslint/js": "^9.21.0", 63 | "@types/node": "^22.13.11", 64 | "@types/react": "^19.0.10", 65 | "@types/react-dom": "^19.0.4", 66 | "@vitejs/plugin-react": "^4.3.4", 67 | "eslint": "^9.21.0", 68 | "eslint-plugin-react-hooks": "^5.1.0", 69 | "eslint-plugin-react-refresh": "^0.4.19", 70 | "globals": "^15.15.0", 71 | "tailwindcss": "^4.1.1", 72 | "typescript": "~5.7.2", 73 | "typescript-eslint": "^8.24.1", 74 | "vite": "^6.2.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pic/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo.png -------------------------------------------------------------------------------- /pic/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo2.png -------------------------------------------------------------------------------- /pic/demo2_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo2_en.png -------------------------------------------------------------------------------- /pic/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo3.png -------------------------------------------------------------------------------- /pic/demo3_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo3_en.png -------------------------------------------------------------------------------- /pic/demo_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo_dark.png -------------------------------------------------------------------------------- /pic/demo_dark_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo_dark_en.png -------------------------------------------------------------------------------- /pic/demo_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/demo_en.png -------------------------------------------------------------------------------- /pic/fnos_docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/fnos_docker.png -------------------------------------------------------------------------------- /pic/fnos_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/fnos_file.png -------------------------------------------------------------------------------- /pic/fnos_file_struct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hisproc/transmission-next-ui/81aadc6d89e52dd29c3cac0194f8f8e4c02b86d6/pic/fnos_file_struct.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/synology.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | RELEASE_ZIP="release.zip" 6 | TARGET_SUBPATH="/@appstore/transmission/share/transmission/public_html" 7 | 8 | # 查找目标路径 9 | for vol in /volume*; do 10 | TARGET_PATH="$vol$TARGET_SUBPATH" 11 | if [ -d "$TARGET_PATH" ]; then 12 | echo "✅ Found transmission web path at: $TARGET_PATH" 13 | 14 | # 备份当前目录内容 15 | BACKUP_ZIP="./backup.zip" 16 | echo "🗂️ Backing up current contents to $BACKUP_ZIP" 17 | rm -f "$BACKUP_ZIP" 18 | cd "$TARGET_PATH" 19 | zip -r "$OLDPWD/backup.zip" ./* 20 | cd - > /dev/null 21 | 22 | # 清空目标目录 23 | echo "🧹 Clearing old files..." 24 | rm -rf "$TARGET_PATH"/* 25 | 26 | # 解压 release.zip 到目标目录 27 | echo "📦 Unzipping release.zip to $TARGET_PATH" 28 | unzip -o "$RELEASE_ZIP" -d "$TARGET_PATH" 29 | 30 | # 修改权限(让群辉 web 服务能访问) 31 | echo "🔐 Setting permissions..." 32 | chmod -R 755 "$TARGET_PATH" 33 | 34 | echo "✅ Deployment complete!" 35 | exit 0 36 | fi 37 | done 38 | 39 | echo "❌ No valid Transmission public_html path found." 40 | exit 1 41 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | margin: 0 auto; 3 | text-align: left; 4 | font-family: 'MapleMono-NF-CN-Regular', 'Microsoft YaHei', 'PingFang SC', sans-serif; 5 | } 6 | 7 | body { 8 | background-color: #fafafa; 9 | 10 | } 11 | 12 | .logo { 13 | height: 6em; 14 | padding: 1.5em; 15 | will-change: filter; 16 | transition: filter 300ms; 17 | } 18 | 19 | .logo:hover { 20 | filter: drop-shadow(0 0 2em #646cffaa); 21 | } 22 | 23 | .logo.react:hover { 24 | filter: drop-shadow(0 0 2em #61dafbaa); 25 | } 26 | 27 | @keyframes logo-spin { 28 | from { 29 | transform: rotate(0deg); 30 | } 31 | 32 | to { 33 | transform: rotate(360deg); 34 | } 35 | } 36 | 37 | @media (prefers-reduced-motion: no-preference) { 38 | a:nth-of-type(2) .logo { 39 | animation: logo-spin infinite 20s linear; 40 | } 41 | } 42 | 43 | .card { 44 | padding: 2em; 45 | } 46 | 47 | .read-the-docs { 48 | color: #888; 49 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import { AppSidebar } from "@/components/AppSidebar" 3 | import { TorrentManager } from "@/components/TorrentManager" 4 | import { SectionCards } from "@/components/SectionCards" 5 | import { SiteHeader } from "@/components/SiteHeader" 6 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" 7 | import { Routes, Route } from 'react-router-dom'; 8 | 9 | import { allTorrentFields, getFreeSpace, getSession, getSessionStats, getTorrents } from './lib/transmissionClient' 10 | import { Toaster } from '@/components/ui/sonner' 11 | import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' 12 | import { SessionSetting } from './components/settings/SessionSetting' 13 | import About from './components/About' 14 | import { ThemeProvider } from './components/ThemeProvider.tsx' 15 | 16 | const client = new QueryClient() 17 | function Main() { 18 | 19 | const { data: torrentData } = useQuery({ 20 | queryKey: ['torrent'], 21 | queryFn: () => getTorrents({ fields: allTorrentFields }), 22 | refetchInterval: 5000, 23 | select: (data) => data.torrents 24 | }) 25 | 26 | const { data: sessionStats } = useQuery({ 27 | queryKey: ['torrent', 'stats'], 28 | queryFn: () => getSessionStats(), 29 | refetchInterval: 5000, 30 | }) 31 | 32 | const { data: session } = useQuery({ 33 | queryKey: ['torrent', 'session'], 34 | queryFn: () => getSession(), 35 | refetchInterval: 5000, 36 | }) 37 | 38 | const { data: freeSpace } = useQuery({ 39 | queryKey: ['torrent, free-space'], 40 | queryFn: () => getFreeSpace(session?.["download-dir"]), 41 | refetchInterval: 5000, 42 | enabled: !!session 43 | }) 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 |
56 | 57 | 58 | 64 | 65 | } /> 66 | 67 | 68 | } /> 69 | 71 | } 72 | /> 73 | 74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 | ) 82 | } 83 | function App() { 84 | return ( 85 | 86 |
87 | 88 | ) 89 | } 90 | 91 | export default App 92 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/About.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from "@/components/ui/card"; 2 | import { TransmissionSession } from "@/lib/types"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | export default function About({ session }: { session: TransmissionSession }) { 6 | const { t } = useTranslation(); 7 | const version = import.meta.env.VITE_APP_VERSION || "unknown"; 8 | return ( 9 |
10 | 11 | 12 |
13 |

{t("Transmission Version")}: {session?.version}

14 |

{t("UI Version")}: {version}

15 |

{t("RPC Version")}: {session?.["rpc-version"]}

16 |
17 |
18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/AppSidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | IconHelp, 4 | IconInnerShadowTop, 5 | IconSettings, 6 | } from "@tabler/icons-react" 7 | 8 | import { NavMain } from "@/components/NavMain" 9 | import { 10 | Sidebar, 11 | SidebarContent, 12 | SidebarFooter, 13 | SidebarHeader, 14 | SidebarMenu, 15 | SidebarMenuButton, 16 | SidebarMenuItem, 17 | } from "@/components/ui/sidebar" 18 | import { LayoutDashboardIcon } from "lucide-react" 19 | 20 | const data = { 21 | navMain: [ 22 | { 23 | title: "Dashboard", 24 | url: "/", 25 | icon: LayoutDashboardIcon, 26 | }, 27 | { 28 | title: "Settings", 29 | url: "/settings", 30 | icon: IconSettings, 31 | }, 32 | { 33 | title: "About", 34 | url: "/about", 35 | icon: IconHelp, 36 | } 37 | ], 38 | } 39 | 40 | export function AppSidebar({ ...props }: React.ComponentProps) { 41 | return ( 42 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | Transmission 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {/* */} 63 |
64 | © 2025 hisproc 65 |
66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import { cn } from "@/lib/utils" 3 | import { useTranslation } from "react-i18next" 4 | 5 | interface FileUploadProps { 6 | value?: File | null 7 | onChange?: (file: File) => void 8 | accept?: string 9 | maxSize?: number 10 | } 11 | 12 | export function FileUpload({ value, onChange, accept, maxSize }: FileUploadProps) { 13 | const [fileName, setFileName] = useState(null) 14 | const [error, setError] = useState(null) 15 | const inputRef = useRef(null) 16 | const dropRef = useRef(null) 17 | const { t } = useTranslation() 18 | 19 | const handleFile = (file: File) => { 20 | if (maxSize && file.size > maxSize) { 21 | setError("File is too large.") 22 | return 23 | } 24 | setFileName(file.name) 25 | setError(null) 26 | onChange?.(file) 27 | } 28 | 29 | const onFileChange = (e: React.ChangeEvent) => { 30 | const file = e.target.files?.[0] 31 | if (file) handleFile(file) 32 | } 33 | 34 | const onDrop = (e: React.DragEvent) => { 35 | e.preventDefault() 36 | const file = e.dataTransfer.files?.[0] 37 | if (file) handleFile(file) 38 | } 39 | 40 | const onDragOver = (e: React.DragEvent) => { 41 | e.preventDefault() 42 | e.stopPropagation() 43 | } 44 | 45 | useEffect(() => { 46 | if (value && value.name !== fileName) { 47 | setFileName(value.name) 48 | setError(null) 49 | } 50 | }, [value]) 51 | 52 | return ( 53 |
54 |
inputRef.current?.click()} 62 | > 63 | 70 |

71 | {t("Drag and drop a file here, or click to select one")} 72 |

73 | {fileName && ( 74 |

75 | {fileName} 76 |

77 | )} 78 | {error &&

{error}

} 79 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu" 10 | import { useTheme } from "@/components/ThemeProvider.tsx" 11 | 12 | export function ModeToggle() { 13 | const { setTheme } = useTheme() 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme("light")}> 26 | Light 27 | 28 | setTheme("dark")}> 29 | Dark 30 | 31 | setTheme("system")}> 32 | System 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/NavMain.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | SidebarGroup, 4 | SidebarGroupContent, 5 | SidebarMenu, 6 | SidebarMenuButton, 7 | SidebarMenuItem, 8 | } from "@/components/ui/sidebar" 9 | import { useTranslation } from "react-i18next" 10 | import { NavLink } from "react-router-dom" 11 | 12 | export function NavMain({ 13 | items, 14 | }: { 15 | items: { 16 | title: string 17 | url: string 18 | icon?: any 19 | }[] 20 | }) { 21 | 22 | const { t } = useTranslation() 23 | return ( 24 | 25 | 26 | 27 | {items.map((item) => ( 28 | 29 | 30 | {({ isActive }) => ( 31 | 35 | {item.icon && } 36 | {t(item.title)} 37 | 38 | )} 39 | 40 | 41 | ))} 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/SectionCards.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardDescription, 4 | CardFooter, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card" 8 | import { filesize } from "filesize" 9 | import { FreeSpace, SessionStats, Torrent, TransmissionSession } from "@/lib/types" 10 | import { useTranslation } from "react-i18next" 11 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip.tsx"; 12 | import { STORAGE_KEYS } from "@/constants/storage"; 13 | 14 | function summarySpeed(torrentData: Torrent[], field: string) { 15 | return torrentData.reduce((acc, torrent) => { 16 | const speed = field === "uploadSpeed" ? torrent.rateUpload : torrent.rateDownload; 17 | return acc + speed; 18 | }, 0); 19 | } 20 | 21 | export function SectionCards({ torrentData, data, session, freespace }: { torrentData: Torrent[], data: SessionStats, session: TransmissionSession, freespace: FreeSpace }) { 22 | 23 | const { t } = useTranslation(); 24 | 25 | const clientNetworkSpeedSummary = localStorage.getItem(STORAGE_KEYS.CLIENT_NETWORK_SPEED_SUMMARY) === "true"; 26 | 27 | return ( 28 |
29 | 30 | 31 | {t("Upload Speed")} 32 | 33 | {clientNetworkSpeedSummary ? 34 | filesize(summarySpeed(torrentData, "uploadSpeed") || 0) : 35 | filesize(data?.uploadSpeed || 0) 36 | } /s 37 | 38 | 39 | 40 |
41 | {t("Session Upload Size")} 42 | {filesize(data?.["current-stats"]?.uploadedBytes || 0)} 43 |
44 |
45 | {t("Total Upload Size")} 46 | {filesize(data?.["cumulative-stats"]?.uploadedBytes || 0)} 47 |
48 |
49 |
50 | 51 | 52 | {t("Download Speed")} 53 | 54 | {clientNetworkSpeedSummary ? 55 | filesize(summarySpeed(torrentData, "downloadSpeed") || 0) : 56 | filesize(data?.downloadSpeed || 0) 57 | } /s 58 | 59 | 60 | 61 |
62 | {t("Session Download")} 63 | {filesize(data?.["current-stats"]?.downloadedBytes || 0)} 64 |
65 |
66 | {t("Total Download")} 67 | {filesize(data?.["cumulative-stats"]?.downloadedBytes || 0)} 68 |
69 |
70 |
71 | 72 | 73 | {t("Active Torrents")} 74 | 75 | {data?.activeTorrentCount || 0} 76 | 77 | 78 | 79 |
80 | {t("Total Torrents")} 81 | {data?.torrentCount || 0} 82 |
83 |
84 | {t("Paused Torrents")} 85 | {data?.pausedTorrentCount || 0} 86 |
87 |
88 |
89 | 90 | 91 | {t("Free Space")} 92 | 93 | {filesize(freespace["size-bytes"] || 0)} 94 | 95 | 96 | 97 |
98 | {t("Total Space")} 99 | {filesize(freespace["total_size"] || 0)} 100 |
101 |
102 | {t("Path")} 103 | 104 | 105 | 106 | {session["download-dir"]} 107 | 108 | 109 | {session["download-dir"]} 110 | 111 | 112 | 113 |
114 |
115 |
116 |
117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/components/SiteHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { Separator } from "@/components/ui/separator" 3 | import { SidebarTrigger } from "@/components/ui/sidebar" 4 | import { FaGithub } from "react-icons/fa"; 5 | import { GrUpgrade } from "react-icons/gr"; 6 | import { IoLanguage } from "react-icons/io5"; 7 | import { useTranslation } from "react-i18next"; 8 | import { useLocation } from "react-router-dom"; 9 | import { useEffect, useState } from "react"; 10 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; 11 | import axios from "axios"; 12 | import { Tooltip, TooltipContent, TooltipProvider } from "./ui/tooltip"; 13 | import { TooltipTrigger } from "@radix-ui/react-tooltip"; 14 | import { ModeToggle } from "./ModeToggle.tsx"; 15 | 16 | export function SiteHeader() { 17 | const location = useLocation(); 18 | const { i18n } = useTranslation(); 19 | const { t } = useTranslation(); 20 | const [newVersion, setNewVersion] = useState(false); 21 | 22 | useEffect(() => { 23 | axios.get("https://api.github.com/repos/hisproc/transmission-next-ui/releases/latest") 24 | .then((res) => res.data) 25 | .then((data) => { 26 | const latest = data.tag_name; 27 | const current = import.meta.env.VITE_APP_VERSION || "unknown"; 28 | if (latest && current && latest !== current) { 29 | setNewVersion(true); 30 | console.log("New version available:", latest); 31 | console.log("Current version:", current); 32 | } 33 | }); 34 | }, []); 35 | 36 | return ( 37 |
38 |
39 | 40 | 44 |

45 | {location.pathname === "/" && t("Dashboard")} 46 | {location.pathname === "/settings" && t("Settings")} 47 | {location.pathname === "/about" && t("About")} 48 |

49 |
50 | {newVersion && ( 51 | 52 | 53 | 54 | 59 | 60 | 61 | {t("New version available")} 62 | 63 | 64 | 65 | )} 66 | 76 | 77 | 78 | 81 | 82 | 83 | i18n.changeLanguage("en")}> 84 | English 85 | 86 | i18n.changeLanguage("zh")}> 87 | 中文 88 | 89 | 90 | 91 | 92 |
93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ) 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement 35 | 36 | root.classList.remove("light", "dark") 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light" 43 | 44 | root.classList.add(systemTheme) 45 | return 46 | } 47 | 48 | root.classList.add(theme) 49 | }, [theme]) 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme) 55 | setTheme(theme) 56 | }, 57 | } 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext) 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider") 71 | 72 | return context 73 | } 74 | -------------------------------------------------------------------------------- /src/components/ToastLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from 'sonner'; 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | 15 | ); 16 | } -------------------------------------------------------------------------------- /src/components/TorrentToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { IconPlus, IconChevronDown, IconClipboardCheck } from "@tabler/icons-react"; 2 | import { Label } from "recharts"; 3 | import { FileUpload } from "./FileUpload"; 4 | import { useAddTorrent } from "../hooks/useTorrentActions"; 5 | import { Button } from "./ui/button"; 6 | import { DialogHeader, DialogFooter, Dialog, DialogTrigger, DialogContent, DialogTitle, DialogClose } from "./ui/dialog"; 7 | import { Input } from "./ui/input"; 8 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; 9 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; 10 | import { toast } from "sonner"; 11 | import { useTranslation } from "react-i18next"; 12 | 13 | export function TorrentToolbar({ fileProps, directoryProps, openProps, filenameProps }: { 14 | fileProps: { file: File | null, setFile: (file: File | null) => void }, 15 | directoryProps: { defaultDirectory: string, directories: string[], setDirectory: (dir: string) => void }, 16 | openProps: { open: boolean, onOpenChange: (open: boolean) => void }, 17 | filenameProps: { filename: string, setFilename: (filename: string) => void } 18 | }) { 19 | 20 | const addTorrent = useAddTorrent(); 21 | 22 | const { t } = useTranslation(); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 32 | 33 | 34 | 35 | {t("Add Torrent")} 36 | 37 |
38 |
39 | 40 |
41 | directoryProps.setDirectory(e.target.value)} 45 | placeholder={t("Enter or select a directory")} 46 | className="pr-10" 47 | /> 48 | 49 | 50 | 57 | 58 | 59 | {directoryProps.directories.map((dir) => ( 60 | directoryProps.setDirectory(dir)}> 61 | {dir} 62 | 63 | ))} 64 | 65 | 66 |
67 |
68 |
69 | fileProps.setFile(file)} 74 | /> 75 |
76 |
77 | filenameProps.setFilename(e.target.value)} 82 | className="flex-1" 83 | /> 84 | 85 | 86 | 87 | 101 | 102 | {t("Paste from clipboard")} 103 | 104 | 105 |
106 |
107 | 108 | 109 | 125 | 126 | 127 |
128 |
129 | 130 | ) 131 | } -------------------------------------------------------------------------------- /src/components/dialog/AddDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, DialogClose, 3 | DialogContent, 4 | DialogDescription, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle, 8 | } from "@/components/ui/dialog.tsx"; 9 | import { Button } from "@/components/ui/button.tsx"; 10 | import { IconChevronDown, IconClipboardCheck } from "@tabler/icons-react"; 11 | import { Label } from "recharts"; 12 | import { Input } from "@/components/ui/input.tsx"; 13 | import { 14 | DropdownMenu, 15 | DropdownMenuContent, 16 | DropdownMenuItem, 17 | DropdownMenuTrigger 18 | } from "@/components/ui/dropdown-menu.tsx"; 19 | import { FileUpload } from "@/components/FileUpload.tsx"; 20 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip.tsx"; 21 | import { toast } from "sonner"; 22 | import { useEffect, useState } from "react"; 23 | import { useTranslation } from "react-i18next"; 24 | import { useAddTorrent } from "@/hooks/useTorrentActions.ts"; 25 | 26 | export function AddDialog({ open, onOpenChange, file, setFile, directories }: { open: boolean, onOpenChange: (open: boolean) => void, file: File | null, setFile: (file: File | null) => void, directories: string[] }) { 27 | 28 | const [directory, setDirectory] = useState(directories[0] ?? ""); 29 | 30 | const addTorrent = useAddTorrent(); 31 | const [filename, setFilename] = useState(""); 32 | const { t } = useTranslation(); 33 | 34 | useEffect(() => { 35 | if (open) { 36 | setDirectory(directories[0] ?? ""); 37 | } 38 | if (!open) { 39 | setFile(null); 40 | setFilename(""); 41 | } 42 | }, [open]); 43 | 44 | return ( 45 | 46 | 47 | 48 | {t("Add Torrent")} 49 | 50 | 51 | 52 |
53 |
54 | 55 |
56 | setDirectory(e.target.value)} 60 | placeholder={t("Enter or select a directory")} 61 | className="pr-10" 62 | /> 63 | 64 | 65 | 72 | 73 | 74 | {directories.map((dir) => ( 75 | setDirectory(dir)}> 76 | {dir} 77 | 78 | ))} 79 | 80 | 81 |
82 |
83 |
84 | setFile(file)} 89 | /> 90 |
91 |
92 | setFilename(e.target.value)} 97 | className="flex-1" 98 | /> 99 | 100 | 101 | 102 | 116 | 117 | {t("Paste from clipboard")} 118 | 119 | 120 |
121 |
122 | 123 | 124 | 141 | 142 | 143 |
144 |
145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /src/components/dialog/DeleteDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog" 3 | import { Button } from "../ui/button"; 4 | import { Checkbox } from "../ui/checkbox"; 5 | import { Label } from "../ui/label"; 6 | import { Row } from "@tanstack/react-table"; 7 | import { useEffect, useState } from "react"; 8 | import { useDeleteTorrent } from "@/hooks/useTorrentActions"; 9 | import {torrentSchema} from "@/schemas/torrentSchema.ts"; 10 | 11 | export function DeleteDialog({ open, onOpenChange, targetRows }: { open: boolean, onOpenChange: (open: boolean) => void, targetRows: Row[] }) { 12 | const { t } = useTranslation(); 13 | const deleteTorrent = useDeleteTorrent(); 14 | const [deleteData, setDeleteData] = useState(false); 15 | 16 | 17 | useEffect(() => { 18 | if (open) { 19 | setDeleteData(false); 20 | } 21 | }, [open]); 22 | 23 | return ( 24 | 25 | 26 | 27 | {t("Are you sure you want to do this?")} 28 | 29 | {t("The following torrents will be deleted")}: 30 | 31 |
    32 | {targetRows.map((row) => ( 33 |
  • {row.original.name}
  • 34 | ))} 35 |
36 |
37 | setDeleteData(!deleteData)} /> 38 | 39 |
40 |
41 | 42 | 43 | 46 | 47 | 48 |
49 |
50 | ) 51 | 52 | } -------------------------------------------------------------------------------- /src/components/dialog/EditDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog" 2 | import { Button } from "../ui/button"; 3 | import { Checkbox } from "../ui/checkbox"; 4 | import { Label } from "../ui/label"; 5 | import { Row } from "@tanstack/react-table"; 6 | import { useTranslation } from "react-i18next"; 7 | import { Input } from "../ui/input"; 8 | import {useRenamePathTorrent, useSetLocationTorrent, useSetTorrent} from "@/hooks/useTorrentActions"; 9 | import { useEffect, useState } from "react"; 10 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; 11 | import { IconChevronDown } from "@tabler/icons-react"; 12 | import { torrentSchema } from "@/schemas/torrentSchema.ts"; 13 | import {TorrentLabel} from "@/lib/torrentLabel.ts"; 14 | import {LabelEdit} from "@/components/dialog/LabelEdit.tsx"; 15 | 16 | export function EditDialog({ open, onOpenChange, targetRows, directories }: { open: boolean, onOpenChange: (open: boolean) => void, targetRows: Row[], directories: string[] }) { 17 | const { t } = useTranslation(); 18 | 19 | const renamePathTorrent = useRenamePathTorrent(); 20 | const setLocationTorrent = useSetLocationTorrent(); 21 | const setTorrent = useSetTorrent(); 22 | const row = targetRows?.[0]; 23 | const [oldPathname, oldLocation] = [row?.original.name, row?.original.downloadDir, row?.original.labels] 24 | const [location, setLocation] = useState(row?.original.downloadDir) 25 | const [moveData, setMoveData] = useState(false) 26 | const [pathname, setPathname] = useState(row?.original.name) 27 | const [labels, setLabels] = useState([]) 28 | 29 | 30 | useEffect(() => { 31 | setPathname(row?.original.name || "") 32 | setLocation(row?.original.downloadDir || "") 33 | setLabels(row?.getValue("Labels") || []) 34 | }, [open]) 35 | 36 | return ( 37 | 38 | 39 | 40 | {t("Edit")} 41 | 42 | {t("EditingFollowingTorrent")} "{row?.original.name}". 43 | 44 | 45 |
46 |
47 | 48 | setPathname(e.target.value)} /> 49 |
50 |
51 | 52 |
53 | setLocation(e.target.value)} 57 | placeholder="Enter or select a directory" 58 | className="pr-10" 59 | /> 60 | 61 | 62 | 69 | 70 | 71 | {directories.map((dir) => ( 72 | setLocation(dir)}> 73 | {dir} 74 | 75 | ))} 76 | 77 | 78 |
79 |
80 |
81 | 82 |
83 |
84 | 85 | setMoveData(!moveData)} /> 86 |
87 |
88 | 89 | 90 | 101 | 102 | 103 |
104 |
105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /src/components/dialog/LabelEdit.tsx: -------------------------------------------------------------------------------- 1 | import {Input} from "@/components/ui/input.tsx"; 2 | import {Button} from "@/components/ui/button.tsx"; 3 | import {TorrentLabel} from "@/lib/torrentLabel.ts"; 4 | import React, {useState} from "react"; 5 | import {useTranslation} from "react-i18next"; 6 | 7 | interface LabelEditProps { 8 | labels: TorrentLabel[]; 9 | setLabels: React.Dispatch> 10 | } 11 | export function LabelEdit({labels, setLabels}: LabelEditProps) { 12 | 13 | const [newLabel, setNewLabel] = useState(); 14 | 15 | const [showLabelInput, setShowLabelInput] = useState(false); 16 | 17 | const {t} = useTranslation(); 18 | 19 | return ( 20 |
21 | {labels.map((label, index) => ( 22 |
26 | {label.text} 27 | 38 |
39 | ))} 40 | {showLabelInput ? ( 41 | setNewLabel({ text: e.target.value })} 46 | onKeyDown={(e) => { 47 | if (e.key === "Enter" && newLabel?.text.trim()) { 48 | if (!labels.includes(newLabel)) { 49 | setLabels([...labels, newLabel]); 50 | } 51 | setNewLabel(undefined); 52 | setShowLabelInput(false); 53 | } else if (e.key === "Escape") { 54 | setNewLabel(undefined); 55 | setShowLabelInput(false); 56 | } 57 | }} 58 | onBlur={() => { 59 | if (newLabel?.text.trim()) { 60 | if (!labels.includes(newLabel)) { 61 | setLabels([...labels, newLabel]); 62 | } 63 | } 64 | setNewLabel(undefined); 65 | setShowLabelInput(false); 66 | }} 67 | /> 68 | ) : ( 69 | 72 | )} 73 |
74 | ) 75 | } -------------------------------------------------------------------------------- /src/components/settings/NumbericInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Input } from "../ui/input"; 3 | 4 | type NumericInputProps = { 5 | value: number; 6 | onChange: (val: number) => void; 7 | disabled?: boolean; 8 | id?: string; 9 | className?: string; 10 | placeholder?: string; 11 | }; 12 | 13 | export function NumericInput({ value, onChange, ...rest }: NumericInputProps) { 14 | const [internalValue, setInternalValue] = useState(String(value)); 15 | 16 | useEffect(() => { 17 | setInternalValue(String(value)); 18 | }, [value]); 19 | 20 | return ( 21 | { 25 | const val = e.target.value; 26 | setInternalValue(val); 27 | if (/^\d*$/.test(val)) { 28 | onChange(val === "" ? 0 : Number(val)); 29 | } 30 | }} 31 | {...rest} 32 | /> 33 | ); 34 | } -------------------------------------------------------------------------------- /src/components/table/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconDotsVertical, IconEdit, IconPlayerPlay, IconPlayerStop, IconTrash } from "@tabler/icons-react"; 2 | import { Button } from "../ui/button"; 3 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Row } from "@tanstack/react-table"; 6 | import { useStartTorrent, useStopTorrent } from "@/hooks/useTorrentActions"; 7 | import { torrentSchema } from "@/schemas/torrentSchema"; 8 | import { RowAction } from "@/lib/rowAction"; 9 | import { DialogType } from "@/lib/types"; 10 | import React from "react"; 11 | 12 | interface ActionButtonProps { 13 | row: Row; 14 | setRowAction: React.Dispatch>; 15 | } 16 | export function ActionButton({ row, setRowAction }: ActionButtonProps) { 17 | 18 | const { t } = useTranslation(); 19 | const stopTorrent = useStopTorrent(); 20 | const startTorrent = useStartTorrent(); 21 | 22 | return ( 23 | 24 | 25 | 33 | 34 | 35 | { 36 | setRowAction({ dialogType: DialogType.Edit, targetRows: [row] }) 37 | }}> 38 | 39 | {t("Edit")} 40 | 41 | {row.original.status === 0 && ( 42 | startTorrent.mutate([row.original.id])}> 43 | 44 | {t("Start")} 45 | 46 | )} 47 | {row.original.status !== 0 && ( 48 | stopTorrent.mutate([row.original.id])}> 49 | 50 | {t("Stop")} 51 | 52 | )} 53 | 54 | {setRowAction({ dialogType: DialogType.Delete, targetRows: [row] })}}> 55 | 56 | {t("Delete")} 57 | 58 | 59 | 60 | ) 61 | } -------------------------------------------------------------------------------- /src/components/table/ColumnFilter.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 2 | import { Button } from "../ui/button"; 3 | import { PlusCircle, XCircle } from "lucide-react"; 4 | import { Separator } from "../ui/separator"; 5 | import { Badge } from "../ui/badge"; 6 | import { Column } from "@tanstack/react-table"; 7 | import { torrentSchema } from "@/schemas/torrentSchema"; 8 | import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, CommandSeparator } from "../ui/command"; 9 | import React from "react"; 10 | import {Checkbox} from "@/components/ui/checkbox.tsx"; 11 | import {useTranslation} from "react-i18next"; 12 | 13 | interface ColumnFilterProps { 14 | title: string; 15 | column?: Column; 16 | options: { value: string; label: string }[]; 17 | } 18 | 19 | export function ColumnFilter({title, column, options}: ColumnFilterProps) { 20 | 21 | const {t} = useTranslation(); 22 | const columnFilterValue = column?.getFilterValue(); 23 | const selectedValues = new Set( 24 | Array.isArray(columnFilterValue) ? columnFilterValue : [], 25 | ); 26 | 27 | const onItemSelect = React.useCallback( 28 | (option: { value: string; label: string }, isSelected: boolean) => { 29 | if (!column) return; 30 | 31 | const newSelectedValues = new Set(selectedValues); 32 | if (isSelected) { 33 | newSelectedValues.delete(option.value); 34 | } else { 35 | newSelectedValues.add(option.value); 36 | } 37 | const filterValues = Array.from(newSelectedValues); 38 | column.setFilterValue(filterValues.length ? filterValues : undefined); 39 | }, 40 | [column, selectedValues], 41 | ); 42 | 43 | const onReset = React.useCallback( 44 | (event?: React.MouseEvent) => { 45 | event?.stopPropagation(); 46 | column?.setFilterValue(undefined); 47 | }, 48 | [column], 49 | ); 50 | 51 | return ( 52 | 53 | 54 | 105 | 106 | 107 | 108 | 109 | No results found. 110 | 111 | {options.map((option) => { 112 | const isSelected = selectedValues.has(option.value); 113 | return ( 114 | onItemSelect(option, isSelected)} 117 | > 118 | 119 | {option.label} 120 | 121 | ); 122 | })} 123 | 124 | {selectedValues.size > 0 && ( 125 | <> 126 | 127 | 128 | onReset()} 130 | className="justify-center text-center" 131 | > 132 | {t("Clear filters")} 133 | 134 | 135 | 136 | )} 137 | 138 | 139 | 140 | 141 | 142 | ); 143 | } -------------------------------------------------------------------------------- /src/components/table/ColumnView.tsx: -------------------------------------------------------------------------------- 1 | import {DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu.tsx"; 2 | import { Button } from "../ui/button"; 3 | import {IconChevronDown, IconLayoutColumns } from "@tabler/icons-react"; 4 | import { useTranslation } from "react-i18next"; 5 | import {torrentSchema} from "@/schemas/torrentSchema.ts"; 6 | import {Column} from "@tanstack/react-table"; 7 | 8 | export function ColumnView({ columns }: { columns: Column[] }) { 9 | 10 | const { t } = useTranslation(); 11 | 12 | return ( 13 | 14 | 15 | 21 | 22 | 23 | {columns.filter( 24 | (column) => 25 | typeof column.accessorFn !== "undefined" && 26 | column.getCanHide() 27 | ) 28 | .map((column) => { 29 | return ( 30 | 35 | column.toggleVisibility(value) 36 | } 37 | > 38 | {t(column.id)} 39 | 40 | ) 41 | })} 42 | 43 | 44 | ) 45 | 46 | } -------------------------------------------------------------------------------- /src/components/table/SortableHeader.tsx: -------------------------------------------------------------------------------- 1 | import {IconArrowDown, IconArrowUp} from "@tabler/icons-react"; 2 | import {ChevronsUpDown} from "lucide-react"; 3 | import {cn} from "@/lib/utils.ts"; 4 | import {Column} from "@tanstack/react-table"; 5 | import {torrentSchema} from "@/schemas/torrentSchema.ts"; 6 | 7 | export function SortableHeader({ column, title, className }: { column: Column, title: string, className?: string }) { 8 | const sort = column.getIsSorted() 9 | const icon = 10 | sort === "asc" 11 | ? 12 | : sort === "desc" 13 | ? 14 | : 15 | 16 | return ( 17 |
column.toggleSorting()} 20 | > 21 | {title} 22 | {icon} 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/table/TorrentStatus.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslation} from "react-i18next"; 2 | import {IconAlertTriangle, IconClock, IconDownload, IconLoader, IconPlayerStop, IconUpload} from "@tabler/icons-react"; 3 | 4 | export function TorrentStatus({ error, status }: { error: number; status: number }) { 5 | const { t } = useTranslation() 6 | if (error !== 0) { 7 | return ( 8 | <> 9 | 10 | {t("Error")} 11 | 12 | ) 13 | } 14 | 15 | let statusDetails; 16 | switch (status) { 17 | case 0: 18 | statusDetails = { icon: , text: t("Stopped") }; 19 | break; 20 | case 1: 21 | statusDetails = { icon: , text: t("Queued") }; 22 | break; 23 | case 2: 24 | statusDetails = { icon: , text: t("Verifying") }; 25 | break; 26 | case 3: 27 | statusDetails = { icon: , text: t("Queued") }; 28 | break; 29 | case 4: 30 | statusDetails = { icon: , text: t("Downloading") }; 31 | break; 32 | case 5: 33 | statusDetails = { icon: , text: t("Queued") }; 34 | break; 35 | case 6: 36 | statusDetails = { icon: , text: t("Seeding") }; 37 | break; 38 | default: 39 | statusDetails = { icon: null, text: t("Unknown") }; 40 | } 41 | 42 | return ( 43 | <> 44 | {statusDetails.icon} 45 | {statusDetails.text} 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | function AlertDialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function AlertDialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ) 19 | } 20 | 21 | function AlertDialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | ) 27 | } 28 | 29 | function AlertDialogOverlay({ 30 | className, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 42 | ) 43 | } 44 | 45 | function AlertDialogContent({ 46 | className, 47 | ...props 48 | }: React.ComponentProps) { 49 | return ( 50 | 51 | 52 | 60 | 61 | ) 62 | } 63 | 64 | function AlertDialogHeader({ 65 | className, 66 | ...props 67 | }: React.ComponentProps<"div">) { 68 | return ( 69 |
74 | ) 75 | } 76 | 77 | function AlertDialogFooter({ 78 | className, 79 | ...props 80 | }: React.ComponentProps<"div">) { 81 | return ( 82 |
90 | ) 91 | } 92 | 93 | function AlertDialogTitle({ 94 | className, 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ) 104 | } 105 | 106 | function AlertDialogDescription({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 116 | ) 117 | } 118 | 119 | function AlertDialogAction({ 120 | className, 121 | ...props 122 | }: React.ComponentProps) { 123 | return ( 124 | 128 | ) 129 | } 130 | 131 | function AlertDialogCancel({ 132 | className, 133 | ...props 134 | }: React.ComponentProps) { 135 | return ( 136 | 140 | ) 141 | } 142 | 143 | export { 144 | AlertDialog, 145 | AlertDialogPortal, 146 | AlertDialogOverlay, 147 | AlertDialogTrigger, 148 | AlertDialogContent, 149 | AlertDialogHeader, 150 | AlertDialogFooter, 151 | AlertDialogTitle, 152 | AlertDialogDescription, 153 | AlertDialogAction, 154 | AlertDialogCancel, 155 | } 156 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "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", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "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/70", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "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", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "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", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const ContextMenu = ContextMenuPrimitive.Root 10 | 11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger 12 | 13 | const ContextMenuGroup = ContextMenuPrimitive.Group 14 | 15 | const ContextMenuPortal = ContextMenuPrimitive.Portal 16 | 17 | const ContextMenuSub = ContextMenuPrimitive.Sub 18 | 19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup 20 | 21 | const ContextMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName 41 | 42 | const ContextMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )) 55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName 56 | 57 | const ContextMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 62 | 70 | 71 | )) 72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName 73 | 74 | const ContextMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )) 90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName 91 | 92 | const ContextMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )) 113 | ContextMenuCheckboxItem.displayName = 114 | ContextMenuPrimitive.CheckboxItem.displayName 115 | 116 | const ContextMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )) 136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName 137 | 138 | const ContextMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 153 | )) 154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName 155 | 156 | const ContextMenuSeparator = React.forwardRef< 157 | React.ElementRef, 158 | React.ComponentPropsWithoutRef 159 | >(({ className, ...props }, ref) => ( 160 | 165 | )) 166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName 167 | 168 | const ContextMenuShortcut = ({ 169 | className, 170 | ...props 171 | }: React.HTMLAttributes) => { 172 | return ( 173 | 180 | ) 181 | } 182 | ContextMenuShortcut.displayName = "ContextMenuShortcut" 183 | 184 | export { 185 | ContextMenu, 186 | ContextMenuTrigger, 187 | ContextMenuContent, 188 | ContextMenuItem, 189 | ContextMenuCheckboxItem, 190 | ContextMenuRadioItem, 191 | ContextMenuLabel, 192 | ContextMenuSeparator, 193 | ContextMenuShortcut, 194 | ContextMenuGroup, 195 | ContextMenuPortal, 196 | ContextMenuSub, 197 | ContextMenuSubContent, 198 | ContextMenuSubTrigger, 199 | ContextMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { XIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Dialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function DialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function DialogPortal({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function DialogClose({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function DialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function DialogContent({ 48 | className, 49 | children, 50 | ...props 51 | }: React.ComponentProps) { 52 | return ( 53 | 54 | 55 | 63 | {children} 64 | 65 | 66 | Close 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
80 | ) 81 | } 82 | 83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 84 | return ( 85 |
93 | ) 94 | } 95 | 96 | function DialogTitle({ 97 | className, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 106 | ) 107 | } 108 | 109 | function DialogDescription({ 110 | className, 111 | ...props 112 | }: React.ComponentProps) { 113 | return ( 114 | 119 | ) 120 | } 121 | 122 | export { 123 | Dialog, 124 | DialogClose, 125 | DialogContent, 126 | DialogDescription, 127 | DialogFooter, 128 | DialogHeader, 129 | DialogOverlay, 130 | DialogPortal, 131 | DialogTitle, 132 | DialogTrigger, 133 | } 134 | -------------------------------------------------------------------------------- /src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Drawer as DrawerPrimitive } from "vaul" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Drawer({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return 10 | } 11 | 12 | function DrawerTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return 16 | } 17 | 18 | function DrawerPortal({ 19 | ...props 20 | }: React.ComponentProps) { 21 | return 22 | } 23 | 24 | function DrawerClose({ 25 | ...props 26 | }: React.ComponentProps) { 27 | return 28 | } 29 | 30 | function DrawerOverlay({ 31 | className, 32 | ...props 33 | }: React.ComponentProps) { 34 | return ( 35 | 43 | ) 44 | } 45 | 46 | function DrawerContent({ 47 | className, 48 | children, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 66 |
67 | {children} 68 | 69 | 70 | ) 71 | } 72 | 73 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
80 | ) 81 | } 82 | 83 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { 84 | return ( 85 |
90 | ) 91 | } 92 | 93 | function DrawerTitle({ 94 | className, 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ) 104 | } 105 | 106 | function DrawerDescription({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 116 | ) 117 | } 118 | 119 | export { 120 | Drawer, 121 | DrawerPortal, 122 | DrawerOverlay, 123 | DrawerTrigger, 124 | DrawerClose, 125 | DrawerContent, 126 | DrawerHeader, 127 | DrawerFooter, 128 | DrawerTitle, 129 | DrawerDescription, 130 | } 131 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/components/ui/navigation-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" 3 | import { cva } from "class-variance-authority" 4 | import { ChevronDown } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const NavigationMenu = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 20 | {children} 21 | 22 | 23 | )) 24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName 25 | 26 | const NavigationMenuList = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )) 39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName 40 | 41 | const NavigationMenuItem = NavigationMenuPrimitive.Item 42 | 43 | const navigationMenuTriggerStyle = cva( 44 | "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" 45 | ) 46 | 47 | const NavigationMenuTrigger = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, children, ...props }, ref) => ( 51 | 56 | {children}{" "} 57 | 62 | )) 63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName 64 | 65 | const NavigationMenuContent = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 77 | )) 78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName 79 | 80 | const NavigationMenuLink = NavigationMenuPrimitive.Link 81 | 82 | const NavigationMenuViewport = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 |
87 | 95 |
96 | )) 97 | NavigationMenuViewport.displayName = 98 | NavigationMenuPrimitive.Viewport.displayName 99 | 100 | const NavigationMenuIndicator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 112 |
113 | 114 | )) 115 | NavigationMenuIndicator.displayName = 116 | NavigationMenuPrimitive.Indicator.displayName 117 | 118 | export { 119 | navigationMenuTriggerStyle, 120 | NavigationMenu, 121 | NavigationMenuList, 122 | NavigationMenuItem, 123 | NavigationMenuContent, 124 | NavigationMenuTrigger, 125 | NavigationMenuLink, 126 | NavigationMenuIndicator, 127 | NavigationMenuViewport, 128 | } 129 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ProgressPrimitive from "@radix-ui/react-progress" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Progress({ 7 | className, 8 | value, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 25 | 26 | ) 27 | } 28 | 29 | export { Progress } 30 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Select({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function SelectGroup({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function SelectValue({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function SelectTrigger({ 28 | className, 29 | size = "default", 30 | children, 31 | ...props 32 | }: React.ComponentProps & { 33 | size?: "sm" | "default" 34 | }) { 35 | return ( 36 | 45 | {children} 46 | 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | function SelectContent({ 54 | className, 55 | children, 56 | position = "popper", 57 | ...props 58 | }: React.ComponentProps) { 59 | return ( 60 | 61 | 72 | 73 | 80 | {children} 81 | 82 | 83 | 84 | 85 | ) 86 | } 87 | 88 | function SelectLabel({ 89 | className, 90 | ...props 91 | }: React.ComponentProps) { 92 | return ( 93 | 98 | ) 99 | } 100 | 101 | function SelectItem({ 102 | className, 103 | children, 104 | ...props 105 | }: React.ComponentProps) { 106 | return ( 107 | 115 | 116 | 117 | 118 | 119 | 120 | {children} 121 | 122 | ) 123 | } 124 | 125 | function SelectSeparator({ 126 | className, 127 | ...props 128 | }: React.ComponentProps) { 129 | return ( 130 | 135 | ) 136 | } 137 | 138 | function SelectScrollUpButton({ 139 | className, 140 | ...props 141 | }: React.ComponentProps) { 142 | return ( 143 | 151 | 152 | 153 | ) 154 | } 155 | 156 | function SelectScrollDownButton({ 157 | className, 158 | ...props 159 | }: React.ComponentProps) { 160 | return ( 161 | 169 | 170 | 171 | ) 172 | } 173 | 174 | export { 175 | Select, 176 | SelectContent, 177 | SelectGroup, 178 | SelectItem, 179 | SelectLabel, 180 | SelectScrollDownButton, 181 | SelectScrollUpButton, 182 | SelectSeparator, 183 | SelectTrigger, 184 | SelectValue, 185 | } 186 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export { Separator } 27 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Sheet({ ...props }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function SheetTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function SheetClose({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function SheetPortal({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function SheetOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function SheetContent({ 48 | className, 49 | children, 50 | side = "right", 51 | ...props 52 | }: React.ComponentProps & { 53 | side?: "top" | "right" | "bottom" | "left" 54 | }) { 55 | return ( 56 | 57 | 58 | 74 | {children} 75 | 76 | 77 | Close 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { 85 | return ( 86 |
91 | ) 92 | } 93 | 94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { 95 | return ( 96 |
101 | ) 102 | } 103 | 104 | function SheetTitle({ 105 | className, 106 | ...props 107 | }: React.ComponentProps) { 108 | return ( 109 | 114 | ) 115 | } 116 | 117 | function SheetDescription({ 118 | className, 119 | ...props 120 | }: React.ComponentProps) { 121 | return ( 122 | 127 | ) 128 | } 129 | 130 | export { 131 | Sheet, 132 | SheetTrigger, 133 | SheetClose, 134 | SheetContent, 135 | SheetHeader, 136 | SheetFooter, 137 | SheetTitle, 138 | SheetDescription, 139 | } 140 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@/components/ThemeProvider.tsx" 2 | import { Toaster as Sonner } from "sonner" 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ) 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ) 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent } 65 | -------------------------------------------------------------------------------- /src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" 3 | import { type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { toggleVariants } from "@/components/ui/toggle" 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }) 14 | 15 | function ToggleGroup({ 16 | className, 17 | variant, 18 | size, 19 | children, 20 | ...props 21 | }: React.ComponentProps & 22 | VariantProps) { 23 | return ( 24 | 34 | 35 | {children} 36 | 37 | 38 | ) 39 | } 40 | 41 | function ToggleGroupItem({ 42 | className, 43 | children, 44 | variant, 45 | size, 46 | ...props 47 | }: React.ComponentProps & 48 | VariantProps) { 49 | const context = React.useContext(ToggleGroupContext) 50 | 51 | return ( 52 | 66 | {children} 67 | 68 | ) 69 | } 70 | 71 | export { ToggleGroup, ToggleGroupItem } 72 | -------------------------------------------------------------------------------- /src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TogglePrimitive from "@radix-ui/react-toggle" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", 17 | }, 18 | size: { 19 | default: "h-9 px-2 min-w-9", 20 | sm: "h-8 px-1.5 min-w-8", 21 | lg: "h-10 px-2.5 min-w-10", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | } 29 | ) 30 | 31 | function Toggle({ 32 | className, 33 | variant, 34 | size, 35 | ...props 36 | }: React.ComponentProps & 37 | VariantProps) { 38 | return ( 39 | 44 | ) 45 | } 46 | 47 | export { Toggle, toggleVariants } 48 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 60 | -------------------------------------------------------------------------------- /src/constants/storage.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_KEYS = { 2 | PAGE_SIZE: "torrent-page-size", 3 | CLIENT_NETWORK_SPEED_SUMMARY: "client-network-speed-summary", 4 | }; -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useDragAndDropUpload.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { DialogType } from "@/lib/types"; 3 | import { RowAction } from "@/lib/rowAction"; 4 | 5 | interface UseDragAndDropUploadProps { 6 | setFile: (file: File) => void; 7 | setRowAction: React.Dispatch>; 8 | } 9 | export function useDragAndDropUpload({ setFile, setRowAction }: UseDragAndDropUploadProps) { 10 | const [isDragging, setIsDragging] = useState(false); 11 | const dragCounter = useRef(0); 12 | 13 | useEffect(() => { 14 | const handleDragEnter = (e: DragEvent) => { 15 | e.preventDefault(); 16 | dragCounter.current++; 17 | setIsDragging(true); 18 | }; 19 | 20 | const handleDragLeave = (e: DragEvent) => { 21 | e.preventDefault(); 22 | dragCounter.current--; 23 | if (dragCounter.current <= 0) { 24 | setIsDragging(false); 25 | } 26 | }; 27 | 28 | const handleDragOver = (e: DragEvent) => { 29 | e.preventDefault(); 30 | }; 31 | 32 | const handleDrop = (e: DragEvent) => { 33 | console.log("Dropped"); 34 | e.preventDefault(); 35 | setIsDragging(false); 36 | dragCounter.current = 0; 37 | const files = e.dataTransfer?.files; 38 | if (files && files.length > 0) { 39 | const file = files[0]; 40 | setFile(file); 41 | setRowAction({ 42 | dialogType: DialogType.Add, 43 | targetRows: [], 44 | }); 45 | } 46 | }; 47 | 48 | window.addEventListener("dragenter", handleDragEnter); 49 | window.addEventListener("dragleave", handleDragLeave); 50 | window.addEventListener("dragover", handleDragOver); 51 | window.addEventListener("drop", handleDrop); 52 | 53 | return () => { 54 | window.removeEventListener("dragenter", handleDragEnter); 55 | window.removeEventListener("dragleave", handleDragLeave); 56 | window.removeEventListener("dragover", handleDragOver); 57 | window.removeEventListener("drop", handleDrop); 58 | }; 59 | }, [setFile, setRowAction]); 60 | 61 | return { isDragging, dragCounter }; 62 | } -------------------------------------------------------------------------------- /src/hooks/useTorrentActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addTorrent, 3 | startTorrent, 4 | stopTorrent, 5 | deleteTorrent, 6 | renamePath, 7 | setLocation, 8 | setSession, 9 | portTest, 10 | setTorrent 11 | } from "@/lib/transmissionClient"; 12 | import {PortTestOptions, TransmissionSession} from "@/lib/types"; 13 | import { fileToBase64 } from "@/lib/utils"; 14 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 15 | import { useTranslation } from "react-i18next"; 16 | import { toast } from "sonner"; 17 | import {TorrentLabel} from "@/lib/torrentLabel.ts"; 18 | 19 | 20 | export function useAddTorrent() { 21 | const queryClient = useQueryClient(); 22 | const { t } = useTranslation(); 23 | return useMutation({ 24 | mutationFn: async ({ directory, file, filename }: { directory: string; file?: File | null; filename: string | null }) => { 25 | await addTorrent({ 26 | metainfo: file ? await fileToBase64(file) : undefined, 27 | filename: filename ? filename : undefined, 28 | "download-dir": directory, 29 | paused: false 30 | }); 31 | }, 32 | onSuccess: () => { 33 | toast.success(t("Torrent added successfully"), { 34 | "position": "top-right", 35 | }); 36 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 37 | }, 38 | onError: (error) => { 39 | console.error("Error submitting torrent:", error); 40 | toast.error(`${t("Failed to add torrent")}: ${error.message}`, { 41 | "position": "top-right", 42 | }); 43 | } 44 | }); 45 | } 46 | 47 | export function useDeleteTorrent() { 48 | const queryClient = useQueryClient(); 49 | const { t } = useTranslation(); 50 | return useMutation({ 51 | mutationFn: async ({ ids, deleteData }: { ids: number[], deleteData: boolean }) => { 52 | await deleteTorrent({ 53 | ids: ids, 54 | "delete-local-data": deleteData 55 | }); 56 | }, 57 | onSuccess: () => { 58 | toast.success(t("Torrent deleted successfully"), { 59 | "position": "top-right", 60 | }); 61 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 62 | }, 63 | onError: (error) => { 64 | console.error("Error deleting torrent:", error); 65 | toast.error(t("Failed to delete torrent"), { 66 | "position": "top-right", 67 | }); 68 | } 69 | }); 70 | } 71 | 72 | export function useStartTorrent() { 73 | const queryClient = useQueryClient(); 74 | const { t } = useTranslation(); 75 | return useMutation({ 76 | mutationFn: async (ids: number[]) => { 77 | await startTorrent({ 78 | ids: ids, 79 | }); 80 | }, 81 | onSuccess: () => { 82 | toast.success(t("Torrent started successfully"), { 83 | "position": "top-right", 84 | }); 85 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 86 | }, 87 | onError: (error) => { 88 | console.error("Error starting torrent:", error); 89 | toast.error(t("Failed to start torrent"), { 90 | "position": "top-right" 91 | }); 92 | } 93 | }); 94 | } 95 | 96 | export function useSetTorrent() { 97 | const queryClient = useQueryClient(); 98 | const { t } = useTranslation(); 99 | return useMutation({ 100 | mutationFn: async ({ ids, labels }: { ids: number[]; labels: TorrentLabel[] }) => { 101 | await setTorrent({ 102 | ids: ids, 103 | labels: labels.map((label) => JSON.stringify(label)) 104 | }); 105 | }, 106 | onSuccess: () => { 107 | toast.success(t("Torrent set successfully"), { 108 | "position": "top-right", 109 | }); 110 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 111 | }, 112 | onError: (error) => { 113 | console.error("Error setting torrent label:", error); 114 | toast.error(t("Failed to set torrent label"), { 115 | "position": "top-right" 116 | }); 117 | } 118 | }); 119 | } 120 | 121 | export function useStopTorrent() { 122 | const queryClient = useQueryClient(); 123 | const { t } = useTranslation(); 124 | return useMutation({ 125 | mutationFn: async (ids: number[]) => { 126 | await stopTorrent({ 127 | ids: ids, 128 | }); 129 | }, 130 | onSuccess: () => { 131 | toast.success(t("Torrent stopped successfully"), { 132 | "position": "top-right", 133 | }); 134 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 135 | }, 136 | onError: (error) => { 137 | console.error("Error stopping torrent:", error); 138 | toast.error(t("Failed to stop torrent"), { 139 | "position": "top-right" 140 | } 141 | ); 142 | } 143 | }); 144 | } 145 | 146 | export function useRenamePathTorrent() { 147 | const queryClient = useQueryClient(); 148 | const { t } = useTranslation(); 149 | return useMutation({ 150 | mutationFn: async ({ ids, path, name }: { ids: number[]; path: string; name: string }) => { 151 | await renamePath({ 152 | ids: ids, path: path, name: name 153 | }); 154 | }, 155 | onSuccess: () => { 156 | toast.success(t("Torrent path renamed successfully"), { 157 | "position": "top-right", 158 | }); 159 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 160 | }, 161 | onError: (error) => { 162 | console.error("Error renaming torrent path:", error); 163 | toast.error(t("Failed to rename torrent path"), { 164 | "position": "top-right" 165 | } 166 | ); 167 | } 168 | }); 169 | } 170 | 171 | export function useSetLocationTorrent() { 172 | const queryClient = useQueryClient(); 173 | const { t } = useTranslation(); 174 | return useMutation({ 175 | mutationFn: async ({ ids, location, move }: { ids: number[]; location: string; move: boolean }) => { 176 | await setLocation({ 177 | ids: ids, location: location, move: move 178 | }); 179 | }, 180 | onSuccess: () => { 181 | toast.success(t("Torrent location set successfully"), { 182 | "position": "top-right", 183 | }); 184 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 185 | }, 186 | onError: (error) => { 187 | console.error("Error setting torrent location:", error); 188 | toast.error(t("Failed to set torrent location"), { 189 | "position": "top-right" 190 | } 191 | ); 192 | } 193 | }); 194 | } 195 | 196 | export function useSetSession() { 197 | const queryClient = useQueryClient(); 198 | const { t } = useTranslation(); 199 | return useMutation({ 200 | mutationFn: async (options: TransmissionSession) => { 201 | await setSession(options); 202 | }, 203 | onSuccess: () => { 204 | toast.success(t("Session setting saved successfully"), { 205 | "position": "top-right", 206 | }); 207 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent", "session"] }); }, 1000); 208 | }, 209 | onError: (error) => { 210 | console.error("Error setting session:", error); 211 | toast.error(t("Failed to set session"), { 212 | "position": "top-right" 213 | } 214 | ); 215 | } 216 | }); 217 | } 218 | 219 | export function usePortTest() { 220 | const queryClient = useQueryClient(); 221 | const { t } = useTranslation(); 222 | return useMutation({ 223 | mutationFn: async (options: PortTestOptions) => { 224 | await portTest(options); 225 | }, 226 | onSuccess: () => { 227 | toast.success(t("Port test successfully"), { 228 | "position": "top-right", 229 | }); 230 | setTimeout(() => { queryClient.refetchQueries({ queryKey: ["torrent"] }); }, 1000); 231 | }, 232 | onError: (error) => { 233 | console.error("Error testing port:", error); 234 | toast.error(t("Failed to test port"), { 235 | "position": "top-right" 236 | } 237 | ); 238 | } 239 | }); 240 | } -------------------------------------------------------------------------------- /src/hooks/useTorrentTable.ts: -------------------------------------------------------------------------------- 1 | import { getColumns } from "@/components/table/TorrentColumns.tsx"; 2 | import { STORAGE_KEYS } from "@/constants/storage"; 3 | import { RowAction } from "@/lib/rowAction"; 4 | import { torrentSchema } from "@/schemas/torrentSchema"; 5 | import { ColumnFiltersState, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, SortingState, useReactTable, VisibilityState } from "@tanstack/react-table"; 6 | import React, { useMemo, useState } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | interface UseTorrentTableProps { 10 | tabFilterData: torrentSchema[]; 11 | setRowAction: React.Dispatch>; 12 | } 13 | export function useTorrentTable({ tabFilterData, setRowAction }: UseTorrentTableProps) { 14 | 15 | const [rowSelection, setRowSelection] = useState({}) 16 | const [sorting, setSorting] = useState([{ id: "Added Date", desc: true }]) 17 | const [columnVisibility, setColumnVisibility] = useState({}) 18 | const [columnFilters, setColumnFilters] = useState([]) 19 | const [pagination, setPagination] = useState({ 20 | pageIndex: 0, pageSize: Number(localStorage.getItem(STORAGE_KEYS.PAGE_SIZE)) || 50, 21 | }) 22 | const { t } = useTranslation(); 23 | 24 | const columns = useMemo(() => { 25 | return getColumns({ t, setRowAction }); 26 | }, [t]); 27 | 28 | const table = useReactTable({ 29 | data: tabFilterData, 30 | columns: columns, 31 | state: { 32 | sorting, 33 | columnVisibility, 34 | rowSelection, 35 | columnFilters, 36 | pagination, 37 | }, 38 | getRowId: (row) => row.id.toString(), 39 | enableRowSelection: true, 40 | onRowSelectionChange: setRowSelection, 41 | onSortingChange: setSorting, 42 | onColumnFiltersChange: setColumnFilters, 43 | onColumnVisibilityChange: setColumnVisibility, 44 | onPaginationChange: setPagination, 45 | getCoreRowModel: getCoreRowModel(), 46 | getFilteredRowModel: getFilteredRowModel(), 47 | getPaginationRowModel: getPaginationRowModel(), 48 | getSortedRowModel: getSortedRowModel(), 49 | getFacetedRowModel: getFacetedRowModel(), 50 | getFacetedUniqueValues: getFacetedUniqueValues(), 51 | autoResetPageIndex: false 52 | }) 53 | 54 | return { ...table } 55 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | :root { 7 | --radius: 0.625rem; 8 | --background: oklch(1 0 0); 9 | --foreground: oklch(0.145 0 0); 10 | --card: oklch(1 0 0); 11 | --card-foreground: oklch(0.145 0 0); 12 | --popover: oklch(1 0 0); 13 | --popover-foreground: oklch(0.145 0 0); 14 | --primary: oklch(0.205 0 0); 15 | --primary-foreground: oklch(0.985 0 0); 16 | --secondary: oklch(0.97 0 0); 17 | --secondary-foreground: oklch(0.205 0 0); 18 | --muted: oklch(0.97 0 0); 19 | --muted-foreground: oklch(0.556 0 0); 20 | --accent: oklch(0.97 0 0); 21 | --accent-foreground: oklch(0.205 0 0); 22 | --destructive: oklch(0.577 0.245 27.325); 23 | --border: oklch(0.922 0 0); 24 | --input: oklch(0.922 0 0); 25 | --ring: oklch(0.708 0 0); 26 | --chart-1: oklch(0.646 0.222 41.116); 27 | --chart-2: oklch(0.6 0.118 184.704); 28 | --chart-3: oklch(0.398 0.07 227.392); 29 | --chart-4: oklch(0.828 0.189 84.429); 30 | --chart-5: oklch(0.769 0.188 70.08); 31 | --sidebar: oklch(0.985 0 0); 32 | --sidebar-foreground: oklch(0.145 0 0); 33 | --sidebar-primary: oklch(0.205 0 0); 34 | --sidebar-primary-foreground: oklch(0.985 0 0); 35 | --sidebar-accent: oklch(0.97 0 0); 36 | --sidebar-accent-foreground: oklch(0.205 0 0); 37 | --sidebar-border: oklch(0.922 0 0); 38 | --sidebar-ring: oklch(0.708 0 0); 39 | } 40 | 41 | .dark { 42 | --background: oklch(0.145 0 0); 43 | --foreground: oklch(0.985 0 0); 44 | --card: oklch(0.205 0 0); 45 | --card-foreground: oklch(0.985 0 0); 46 | --popover: oklch(0.205 0 0); 47 | --popover-foreground: oklch(0.985 0 0); 48 | --primary: oklch(0.922 0 0); 49 | --primary-foreground: oklch(0.205 0 0); 50 | --secondary: oklch(0.269 0 0); 51 | --secondary-foreground: oklch(0.985 0 0); 52 | --muted: oklch(0.269 0 0); 53 | --muted-foreground: oklch(0.708 0 0); 54 | --accent: oklch(0.269 0 0); 55 | --accent-foreground: oklch(0.985 0 0); 56 | --destructive: oklch(0.704 0.191 22.216); 57 | --border: oklch(1 0 0 / 10%); 58 | --input: oklch(1 0 0 / 15%); 59 | --ring: oklch(0.556 0 0); 60 | --chart-1: oklch(0.488 0.243 264.376); 61 | --chart-2: oklch(0.696 0.17 162.48); 62 | --chart-3: oklch(0.769 0.188 70.08); 63 | --chart-4: oklch(0.627 0.265 303.9); 64 | --chart-5: oklch(0.645 0.246 16.439); 65 | --sidebar: oklch(0.205 0 0); 66 | --sidebar-foreground: oklch(0.985 0 0); 67 | --sidebar-primary: oklch(0.488 0.243 264.376); 68 | --sidebar-primary-foreground: oklch(0.985 0 0); 69 | --sidebar-accent: oklch(0.269 0 0); 70 | --sidebar-accent-foreground: oklch(0.985 0 0); 71 | --sidebar-border: oklch(1 0 0 / 10%); 72 | --sidebar-ring: oklch(0.556 0 0); 73 | } 74 | 75 | @theme inline { 76 | --radius-sm: calc(var(--radius) - 4px); 77 | --radius-md: calc(var(--radius) - 2px); 78 | --radius-lg: var(--radius); 79 | --radius-xl: calc(var(--radius) + 4px); 80 | --color-background: var(--background); 81 | --color-foreground: var(--foreground); 82 | --color-card: var(--card); 83 | --color-card-foreground: var(--card-foreground); 84 | --color-popover: var(--popover); 85 | --color-popover-foreground: var(--popover-foreground); 86 | --color-primary: var(--primary); 87 | --color-primary-foreground: var(--primary-foreground); 88 | --color-secondary: var(--secondary); 89 | --color-secondary-foreground: var(--secondary-foreground); 90 | --color-muted: var(--muted); 91 | --color-muted-foreground: var(--muted-foreground); 92 | --color-accent: var(--accent); 93 | --color-accent-foreground: var(--accent-foreground); 94 | --color-destructive: var(--destructive); 95 | --color-border: var(--border); 96 | --color-input: var(--input); 97 | --color-ring: var(--ring); 98 | --color-chart-1: var(--chart-1); 99 | --color-chart-2: var(--chart-2); 100 | --color-chart-3: var(--chart-3); 101 | --color-chart-4: var(--chart-4); 102 | --color-chart-5: var(--chart-5); 103 | --color-sidebar: var(--sidebar); 104 | --color-sidebar-foreground: var(--sidebar-foreground); 105 | --color-sidebar-primary: var(--sidebar-primary); 106 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 107 | --color-sidebar-accent: var(--sidebar-accent); 108 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 109 | --color-sidebar-border: var(--sidebar-border); 110 | --color-sidebar-ring: var(--sidebar-ring); 111 | } 112 | 113 | @layer base { 114 | * { 115 | @apply border-border outline-ring/50; 116 | } 117 | body { 118 | @apply bg-background text-foreground; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs" 2 | import duration from "dayjs/plugin/duration" 3 | import durationFormat from "dayjs/plugin/duration" 4 | 5 | dayjs.extend(duration); 6 | dayjs.extend(durationFormat); 7 | 8 | export function formatEta(seconds: number, t: Function) { 9 | 10 | if (seconds < 0) { 11 | return ""; 12 | } 13 | const h = Math.floor(seconds / 3600) 14 | const m = Math.floor((seconds % 3600) / 60) 15 | const s = seconds % 60 16 | 17 | const parts = [] 18 | if (h) parts.push(`${h}${t("h")}`) 19 | if (m) parts.push(`${m}${t("m")}`) 20 | if (s || parts.length === 0) parts.push(`${s}${t("s")}`) 21 | 22 | return parts.join("") 23 | } 24 | 25 | export default dayjs; -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | // src/lib/i18n.ts 2 | import i18n from "i18next" 3 | import { initReactI18next } from "react-i18next" 4 | import LanguageDetector from 'i18next-browser-languagedetector' 5 | import zh from "@/locales/zh-CN.json" 6 | import en from "@/locales/en.json" 7 | 8 | i18n 9 | .use(initReactI18next) 10 | .use(LanguageDetector) 11 | .init({ 12 | fallbackLng: "zh", // 默认语言 13 | supportedLngs: ["en", "zh"], 14 | debug: false, 15 | interpolation: { 16 | escapeValue: false, // React 会自动处理 XSS 17 | }, 18 | resources: { 19 | en: { 20 | translation: en, 21 | }, 22 | zh: { 23 | translation: zh, 24 | }, 25 | }, 26 | detection: { 27 | order: ['localStorage', 'cookie', 'navigator'], 28 | caches: ['localStorage', 'cookie'], 29 | }, 30 | }) 31 | 32 | export default i18n -------------------------------------------------------------------------------- /src/lib/rowAction.ts: -------------------------------------------------------------------------------- 1 | import { torrentSchema } from "@/schemas/torrentSchema"; 2 | import { DialogType } from "./types"; 3 | import { Row } from "@tanstack/react-table"; 4 | 5 | export interface RowAction { 6 | dialogType: DialogType; 7 | targetRows: Row[]; 8 | } -------------------------------------------------------------------------------- /src/lib/torrentLabel.ts: -------------------------------------------------------------------------------- 1 | export interface TorrentLabel { 2 | text: string; 3 | } -------------------------------------------------------------------------------- /src/lib/transmissionClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { 4 | AddTorrentOptions, 5 | DeleteTorrentOptions, 6 | GetTorrentsOptions, 7 | NewLocationOptions, 8 | PortTestOptions, 9 | RenamePathOptions, 10 | SetTorrentOptions, 11 | StopTorrentOptions, 12 | TransmissionSession 13 | } from '@/lib/types'; 14 | 15 | const transmission = axios.create({ 16 | baseURL: import.meta.env.VITE_API_BASE_URL, 17 | timeout: 3000, 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | }, 21 | }); 22 | 23 | let sessionId: null = null; 24 | 25 | transmission.interceptors.request.use(config => { 26 | if (sessionId) { 27 | config.headers['X-Transmission-Session-Id'] = sessionId; 28 | } 29 | return config; 30 | }); 31 | 32 | transmission.interceptors.response.use( 33 | res => res, 34 | async error => { 35 | if (error.response?.status === 409) { 36 | const newSessionId = error.response.headers['x-transmission-session-id']; 37 | if (newSessionId) { 38 | sessionId = newSessionId; 39 | // 重新发送原始请求 40 | const config = error.config; 41 | config.headers['X-Transmission-Session-Id'] = sessionId; 42 | return transmission(config); 43 | } 44 | } 45 | return Promise.reject(error); 46 | } 47 | ); 48 | 49 | export const allTorrentFields = ["id", "name", "status", "hashString", "totalSize", "percentDone", "addedDate", "trackerStats", "leftUntilDone", "rateDownload", "rateUpload", "recheckProgress", "rateDownload", "rateUpload", "peersGettingFromUs", "peersSendingToUs", "uploadRatio", "uploadedEver", "downloadedEver", "downloadDir", "error", "errorString", "doneDate", "queuePosition", "activityDate", "eta", "labels"]; 50 | 51 | export const singleTorrentFields = ["fileStats", "trackerStats", "peers", "leftUntilDone", "status", "rateDownload", "rateUpload", "uploadedEver", "uploadRatio", "error", "errorString", "pieces", "pieceCount", "pieceSize", "files", "trackers", "comment", "dateCreated", "creator", "downloadDir", "hashString", "addedDate", "label"]; 52 | 53 | 54 | export const getTorrents = async (options: GetTorrentsOptions) => { 55 | const payload = { 56 | method: 'torrent-get', 57 | arguments: options, 58 | }; 59 | const response = await transmission.post('', payload); 60 | return response.data.arguments; 61 | } 62 | 63 | /** 64 | * 添加下载任务 65 | * @param {Object} options - 添加参数 66 | * @param {string} [options.filename] - 磁力链接或URL 67 | * @param {string} [options.metainfo] - base64编码的.torrent文件内容 68 | * @param {string} [options.downloadDir] - 指定下载目录 69 | */ 70 | export const addTorrent = async (options: AddTorrentOptions) => { 71 | const payload = { 72 | method: 'torrent-add', 73 | arguments: options, 74 | }; 75 | 76 | const response = await transmission.post('', payload); 77 | if (response.data.result !== 'success') { 78 | throw new Error(response.data.result); 79 | } 80 | return response.data.arguments; 81 | }; 82 | 83 | export const deleteTorrent = async (options: DeleteTorrentOptions) => { 84 | const payload = { 85 | method: 'torrent-remove', 86 | arguments: options 87 | } 88 | if (options.ids.length === 0) { 89 | throw new Error('No torrents selected'); 90 | } 91 | const response = await transmission.post('', payload); 92 | return response.data.arguments; 93 | }; 94 | 95 | export const setTorrent = async (options: SetTorrentOptions) => { 96 | const payload = { 97 | method: 'torrent-set', 98 | arguments: options 99 | } 100 | const response = await transmission.post('', payload); 101 | return response.data.arguments; 102 | } 103 | 104 | export const stopTorrent = async (options: StopTorrentOptions) => { 105 | const payload = { 106 | method: 'torrent-stop', 107 | arguments: options 108 | } 109 | const response = await transmission.post('', payload); 110 | return response.data.arguments; 111 | }; 112 | 113 | export const startTorrent = async (options: StopTorrentOptions) => { 114 | const payload = { 115 | method: 'torrent-start', 116 | arguments: options 117 | } 118 | const response = await transmission.post('', payload); 119 | return response.data.arguments; 120 | } 121 | 122 | export const renamePath = async (options: RenamePathOptions) => { 123 | const payload = { 124 | method: 'torrent-rename-path', 125 | arguments: options, 126 | }; 127 | const response = await transmission.post('', payload); 128 | return response.data.arguments; 129 | }; 130 | 131 | export const setLocation = async (options: NewLocationOptions) => { 132 | const payload = { 133 | method: 'torrent-set-location', 134 | arguments: options, 135 | }; 136 | const response = await transmission.post('', payload); 137 | return response.data.arguments; 138 | }; 139 | 140 | export const getSessionStats = async () => { 141 | const payload = { 142 | method: 'session-stats', 143 | }; 144 | const response = await transmission.post('', payload); 145 | return response.data.arguments; 146 | }; 147 | 148 | export const getFreeSpace = async (path: string) => { 149 | const payload = { 150 | method: 'free-space', 151 | arguments: { 152 | path, 153 | }, 154 | }; 155 | const response = await transmission.post('', payload); 156 | return response.data.arguments; 157 | }; 158 | 159 | export const getSession = async () => { 160 | const payload = { 161 | method: 'session-get', 162 | }; 163 | const response = await transmission.post('', payload); 164 | return response.data.arguments; 165 | }; 166 | 167 | export const setSession = async (options: TransmissionSession) => { 168 | const payload = { 169 | method: 'session-set', 170 | arguments: options, 171 | }; 172 | const response = await transmission.post('', payload); 173 | return response.data.arguments; 174 | } 175 | 176 | export const portTest = async (options: PortTestOptions) => { 177 | const payload = { 178 | method: 'port-test', 179 | arguments: options, 180 | }; 181 | const response = await transmission.post('', payload); 182 | return response.data.arguments; 183 | } 184 | 185 | export default transmission; -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface GetTorrentsOptions { 2 | fields?: string[]; // 要获取的字段列表 3 | ids?: number[]; // 要获取的 torrent ID 列表 4 | } 5 | 6 | export interface AddTorrentOptions { 7 | filename?: string; // 磁力链接 或 URL 8 | metainfo?: string; // base64 编码的 .torrent 文件内容 9 | "download-dir"?: string; // 可选的下载目录 10 | paused?: boolean; // 是否暂停添加 11 | peerLimit?: number; // 同时连接的peer数限制 12 | bandwidthPriority?: number; // 带宽优先级 13 | cookies?: string; // 设置 cookie 14 | } 15 | 16 | export enum DialogType { 17 | Edit = "edit", 18 | Delete = "delete", 19 | Add = "add", 20 | } 21 | 22 | export interface PortTestOptions { 23 | "ip_protocol": string; 24 | } 25 | 26 | export interface PortTestResponse { 27 | "port-is-open": boolean; // Is the port open? 28 | "ip-protocol": string; // Ip protocol used 29 | } 30 | 31 | export interface DeleteTorrentOptions { 32 | ids: number[]; 33 | "delete-local-data"?: boolean; 34 | } 35 | 36 | export interface SetTorrentOptions { 37 | ids: number[]; 38 | labels?: string[]; 39 | } 40 | 41 | export interface StopTorrentOptions { 42 | ids: number[]; 43 | } 44 | 45 | export interface StartTorrentOptions { 46 | ids: number[]; 47 | } 48 | 49 | export interface RenamePathOptions { 50 | ids: number[]; 51 | path: string; 52 | name: string; 53 | } 54 | 55 | export interface NewLocationOptions { 56 | ids: number[]; 57 | location: string; 58 | move: boolean; 59 | } 60 | 61 | export interface SessionStats { 62 | activeTorrentCount: number; 63 | downloadSpeed: number; 64 | pausedTorrentCount: number; 65 | torrentCount: number; 66 | uploadSpeed: number; 67 | "cumulative-stats": StatItem; 68 | "current-stats": StatItem; 69 | } 70 | 71 | export interface StatItem { 72 | downloadedBytes: number; 73 | filesAdded: number; 74 | secondsActive: number; 75 | uploadedBytes: number; 76 | sessionCount: number; 77 | } 78 | 79 | export interface FreeSpace { 80 | "size-bytes": number; 81 | "total_size": number; 82 | } 83 | 84 | export interface TransmissionSession { 85 | "download-dir"?: string; 86 | "speed-limit-down"?: number; 87 | "speed-limit-down-enabled"?: boolean; 88 | "speed-limit-up"?: number; 89 | "speed-limit-up-enabled"?: boolean; 90 | "incomplete-dir"?: string; 91 | "incomplete-dir-enabled"?: boolean; 92 | "peer-port"?: number; 93 | "peer-port-random-on-start"?: boolean; 94 | "utp-enabled"?: boolean; 95 | "rename-partial-files"?: boolean; 96 | "port-forwarding-enabled"?: boolean; 97 | "cache-size-mb"?: number; 98 | "lpd-enabled"?: boolean; 99 | "dht-enabled"?: boolean; 100 | "pex-enabled"?: boolean; 101 | "encryption"?: string; 102 | "queue-stalled-enabled"?: boolean; 103 | "queue-stalled-minutes"?: number; 104 | "download-queue-size"?: number; 105 | "seed-queue-size"?: number; 106 | "download-queue-enabled"?: boolean; 107 | "seed-queue-enabled"?: boolean; 108 | "seedRatioLimit"?: number; 109 | "seedRatioLimited"?: boolean; 110 | "idle-seeding-limit"?: number; 111 | "idle-seeding-limit-enabled"?: boolean; 112 | "rpc-version"?: number; 113 | version?: string; 114 | } 115 | 116 | export interface TrackerStats { 117 | announce: string; 118 | announceState: number; 119 | downloadCount: number; 120 | hasAnnounced: boolean; 121 | hasScraped: boolean; 122 | host: string; 123 | id: number; 124 | isBackup: boolean; 125 | lastAnnouncePeerCount: number; 126 | lastAnnounceResult: string; 127 | lastAnnounceStartTime: number; 128 | lastAnnounceSucceeded: boolean; 129 | lastAnnounceTime: number; 130 | lastAnnounceTimedOut: boolean; 131 | lastScrapeResult: string; 132 | lastScrapeStartTime: number; 133 | lastScrapeSucceeded: boolean; 134 | lastScrapeTime: number; 135 | lastScrapeTimedOut: boolean; 136 | leecherCount: number; 137 | nextAnnounceTime: number; 138 | nextScrapeTime: number; 139 | scrape: string; 140 | scrapeState: number; 141 | seederCount: number; 142 | tier: number; 143 | } 144 | 145 | export interface TorrentFile { 146 | bytesCompleted: number; 147 | length: number; 148 | name: string; 149 | } 150 | 151 | export interface Peer { 152 | address: string; 153 | clientName: string; 154 | clientIsChoked: boolean; 155 | clientIsInterested: boolean; 156 | flagStr: string; 157 | isDownloadingFrom: boolean; 158 | isEncrypted: boolean; 159 | isIncoming: boolean; 160 | isUploadingTo: boolean; 161 | isUTP: boolean; 162 | peerIsChoked: boolean; 163 | peerIsInterested: boolean; 164 | port: number; 165 | progress: number; 166 | rateToClient: number; 167 | rateToPeer: number; 168 | } 169 | 170 | export interface Torrent { 171 | id: number; 172 | name: string; 173 | status: number; 174 | hashString: string; 175 | totalSize: number; 176 | percentDone: number; 177 | addedDate: number; 178 | creator: string; 179 | comment: string; 180 | trackerStats: TrackerStats[]; 181 | pieces: string; 182 | pieceCount: number; 183 | rateDownload: number; 184 | rateUpload: number; 185 | peers: Peer[]; 186 | files: TorrentFile[]; 187 | } 188 | 189 | export interface GetTorrentResponse { 190 | torrents: Torrent[]; 191 | } -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | import {TorrentLabel} from "@/lib/torrentLabel.ts"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | 9 | export function parseLabel(label: string) : TorrentLabel | null { 10 | try { 11 | return JSON.parse(label) as TorrentLabel 12 | } 13 | catch (error) { 14 | console.error("Error parsing labels:", error); 15 | return null; 16 | } 17 | } 18 | 19 | export const fileToBase64 = (file: File): Promise => { 20 | return new Promise((resolve, reject) => { 21 | const reader = new FileReader(); 22 | 23 | reader.onload = () => { 24 | const result = reader.result as string; 25 | // 去掉 "data:application/x-bittorrent;base64," 这样的前缀 26 | const base64 = result.split(',')[1]; 27 | resolve(base64); 28 | }; 29 | 30 | reader.onerror = (error) => reject(error); 31 | 32 | reader.readAsDataURL(file); // 👈 转成 base64 33 | }); 34 | }; -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Name", 3 | "Total Size": "Total Size", 4 | "Percentage": "Percentage", 5 | "Status": "Status", 6 | "Download Rate": "Download Rate", 7 | "Upload Rate": "Upload Rate", 8 | "Download Peers": "Download Peers", 9 | "Upload Peers": "Upload Peers", 10 | "Upload Ratio": "Upload Ratio", 11 | "Uploaded": "Uploaded", 12 | "Added Date": "Added Date", 13 | "Upload Speed": "Upload Speed", 14 | "Session Upload Size": "Session Upload Size", 15 | "Total Upload Size": "Total Upload Size", 16 | "Download Speed": "Download Speed", 17 | "Session Download": "Session Download", 18 | "Total Download": "Total Download", 19 | "Active Torrents": "Active Torrents", 20 | "Total Torrents": "Total Torrents", 21 | "Paused Torrents": "Paused Torrents", 22 | "Free Space": "Free Space", 23 | "Total Space": "Total Space", 24 | "Path": "Path", 25 | "Free Space Size": "Free Space Size", 26 | "Edit": "Edit", 27 | "Stop": "Stop", 28 | "Start": "Start", 29 | "Delete": "Delete", 30 | "Start Selected Torrents": "Start Selected Torrents", 31 | "Stop Selected Torrents": "Stop Selected Torrents", 32 | "Delete Selected Torrents": "Delete Selected Torrents", 33 | "Confirm": "Confirm", 34 | "aa": "bb", 35 | "RowsPerPage": "Rows Per Page", 36 | "Page": "Page", 37 | "Go to first page": "Go to first page", 38 | "Go to last page": "Go to last page", 39 | "Go to previous page": "Go to previous page", 40 | "Go to next page": "Go to next page", 41 | "All": "All", 42 | "Downloading": "Downloading", 43 | "Seeding": "Seeding", 44 | "Stopped": "Stopped", 45 | "Error": "Error", 46 | "Save Path": "Save Path", 47 | "Hash": "Hash", 48 | "Creator": "Creator", 49 | "Comment": "Comment", 50 | "Size": "Size", 51 | "Added At": "Added At", 52 | "Created At": "Created At", 53 | "Total Pieces": "Total pieces", 54 | "Displayed Blocks": "Displayed blocks", 55 | "Info": "Info", 56 | "Peers": "Peers", 57 | "Trackers": "Trackers", 58 | "Files": "Files", 59 | "Close": "Close", 60 | "Customize Columns": "Customize Columns", 61 | "Columns": "Columns", 62 | "Add Torrent": "Add Torrent", 63 | "Save Directory": "Save Directory", 64 | "Enter or select a directory": "Enter or select a directory", 65 | "Paste magnet link or URL": "Paste magnet link or URL", 66 | "Paste from clipboard": "Paste from clipboard", 67 | "Submit": "Submit", 68 | "Please select only one file or magnet link.": "Please select only one file or magnet link.", 69 | "Please select a file or magnet link.": "Please select a file or magnet link.", 70 | "row(s) selected.": "row(s) selected.", 71 | "Dashboard": "Dashboard", 72 | "Settings": "Settings", 73 | "About": "About", 74 | "Bandwidth": "Bandwidth", 75 | "Network": "Network", 76 | "Storage": "Storage", 77 | "Bandwidth Limits": "Bandwidth Limits", 78 | "Upload limit": "Upload limit", 79 | "Download limit": "Download limit", 80 | "Save changes": "Save changes", 81 | "Directory Settings": "Directory Settings", 82 | "Download Directory": "Download Directory", 83 | "Use incomplete directory": "Use incomplete directory", 84 | "Rename partial files with .part": "Rename partial files with .part", 85 | "Peer Setting": "Peer Setting", 86 | "Peer Port": "Peer Port", 87 | "Random": "Random", 88 | "uTP": "uTP", 89 | "Drag and drop a file here, or click to select one": "Drag and drop a file here, or click to select one", 90 | "Port forwarding": "Port forwarding", 91 | "Test port": "Test port", 92 | "Port test successfully.": "Port test successfully.", 93 | "Port": "Port", 94 | "Client": "Client", 95 | "Flags": "Flags", 96 | "Progress": "Progress", 97 | "Download": "Download", 98 | "Upload": "Upload", 99 | "Enable Local Peer Discovery": "Enable Local Peer Discovery", 100 | "Enable DHT": "Enable DHT", 101 | "Enable Peer Exchange": "Enable Peer Exchange", 102 | "Enable uTP": "Enable uTP", 103 | "Disk Settings": "Disk Settings", 104 | "Disk Cache Size": "Disk Cache Size", 105 | "Protocol Settings": "Protocol Settings", 106 | "Encryption Settings": "Encryption Settings", 107 | "Encryption Options": "Encryption Options", 108 | "Preferred Encryption": "Preferred", 109 | "Required Encryption": "Required", 110 | "Tolerated Encryption": "Tolerated", 111 | "Queue": "Queue", 112 | "Queue Settings": "Queue Settings", 113 | "Consider idle torrents as stalled after": "Consider idle torrents as stalled after", 114 | "minutes": "minutes", 115 | "Download Queue": "Download Queue", 116 | "Upload Queue": "Upload Queue", 117 | "Seeding Limits": "Seeding Limits", 118 | "Seed ratio limit": "Seed ratio limit", 119 | "Idle seeding limit": "Idle seeding limit", 120 | "Transmission Version": "Transmission Version", 121 | "UI Version": "UI Version", 122 | "RPC Version": "RPC Version", 123 | "Torrent added successfully": "Torrent added successfully", 124 | "Torrent deleted successfully": "Torrent deleted successfully", 125 | "Torrent started successfully": "Torrent started successfully", 126 | "Torrent stopped successfully": "Torrent stopped successfully", 127 | "Torrent path renamed successfully": "Torrent path renamed successfully", 128 | "Torrent location set successfully": "Torrent location set successfully", 129 | "Port test successfully": "Port test successfully", 130 | "Failed to test port": "Failed to test port", 131 | "Failed to add torrent": "Failed to add torrent", 132 | "Failed to delete torrent": "Failed to delete torrent", 133 | "Failed to start torrent": "Failed to start torrent", 134 | "Failed to stop torrent": "Failed to stop torrent", 135 | "Failed to rename torrent path": "Failed to rename torrent path", 136 | "Failed to set torrent location": "Failed to set torrent location", 137 | "Session setting saved successfully": "Session setting saved successfully", 138 | "Failed to save session setting": "Failed to save session setting", 139 | "Are you sure you want to do this?": "Are you sure you want to do this?", 140 | "The following torrents will be deleted": "The following torrents will be deleted", 141 | "Delete data": "Delete data", 142 | "Move data": "Move data", 143 | "Last Announce": "Last Announce", 144 | "Announce Time": "Announce Time", 145 | "Seeders": "Seeders", 146 | "Leechers": "Leechers", 147 | "Success": "Success", 148 | "Active": "Active", 149 | "h": "h", 150 | "m": "m", 151 | "s": "s", 152 | "eta": "eta", 153 | "Search ...": "Search ...", 154 | "EditingFollowingTorrent": "Edit the following torrent", 155 | "UI Settings": "UI Settings", 156 | "Client Network Speed Summary": "Client Network Speed Summary", 157 | "UI Settings updated": "UI Settings updated", 158 | "Clear filters": "Clear filters", 159 | "Labels": "Labels" 160 | } -------------------------------------------------------------------------------- /src/locales/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "名称", 3 | "Total Size": "总大小", 4 | "Percentage": "下载进度", 5 | "Status": "状态", 6 | "Download Rate": "下载速度", 7 | "Upload Rate": "上传速度", 8 | "Download Peers": "下载(连接)", 9 | "Upload Peers": "上传(连接)", 10 | "Upload Ratio": "分享率", 11 | "Uploaded": "已上传大小", 12 | "Added Date": "添加时间", 13 | "Upload Speed": "上传速度", 14 | "Session Upload Size": "会话总上传", 15 | "Total Upload Size": "历史总上传", 16 | "Download Speed": "下载速度", 17 | "Session Download": "会话总下载", 18 | "Total Download": "历史总下载", 19 | "Active Torrents": "活动种子数量", 20 | "Total Torrents": "总种子数量", 21 | "Paused Torrents": "已暂停数量", 22 | "Free Space": "剩余磁盘空间", 23 | "Total Space": "总磁盘空间", 24 | "Path": "统计路径", 25 | "Edit": "编辑", 26 | "Stop": "停止", 27 | "Start": "开始", 28 | "Delete": "删除", 29 | "Start Selected Torrents": "开始选中的种子", 30 | "Stop Selected Torrents": "停止选中的种子", 31 | "Delete Selected Torrents": "删除选中的种子", 32 | "Confirm": "确认", 33 | "RowsPerPage": "每页行数", 34 | "Page": "当前页", 35 | "Go to first page": "跳转到第一页", 36 | "Go to last page": "跳转到最后一页", 37 | "Go to previous page": "跳转到上一页", 38 | "Go to next page": "跳转到下一页", 39 | "All": "全部", 40 | "Downloading": "下载中", 41 | "Seeding": "做种中", 42 | "Stopped": "已停止", 43 | "Error": "错误", 44 | "Save Path": "保存路径", 45 | "Hash": "哈希值", 46 | "Creator": "创建者", 47 | "Comment": "备注", 48 | "Size": "文件大小", 49 | "Added At": "添加时间", 50 | "Created At": "创建时间", 51 | "Total Pieces": "分片数", 52 | "Displayed Blocks": "展示块数量", 53 | "Info": "基本信息", 54 | "Peers": "节点", 55 | "Trackers": "Tracker", 56 | "Files": "文件", 57 | "Close": "关闭", 58 | "Customize Columns": "自定义列", 59 | "Columns": "列", 60 | "Add Torrent": "添加种子", 61 | "Save Directory": "保存目录", 62 | "Enter or select a directory": "输入或选择保存目录", 63 | "Paste magnet link or URL": "粘贴磁力链接或 URL", 64 | "Paste from clipboard": "从剪贴板粘贴", 65 | "Submit": "提交", 66 | "Please select only one file or magnet link.": "只能选择一个文件或磁力链接", 67 | "Please select a file or magnet link.": "请选择一个文件或磁力链接", 68 | "row(s) selected.": "项已选中", 69 | "Dashboard": "面板 ", 70 | "Settings": "设置", 71 | "About": "关于", 72 | "Bandwidth": "带宽", 73 | "Network": "网络", 74 | "Storage": "存储", 75 | "Bandwidth Limits": "带宽限制", 76 | "Upload limit": "上传限速", 77 | "Download limit": "下载限速", 78 | "Save changes": "保存更改", 79 | "Directory Settings": "目录设置", 80 | "Download Directory": "下载目录", 81 | "Use incomplete directory": "使用临时目录", 82 | "Rename partial files with .part": "在未完成的文件上添加 .part 后缀", 83 | "Peer Setting": "节点连接设置", 84 | "Peer Port": "连接端口", 85 | "Random": "随机端口", 86 | "uTP": "uTP", 87 | "Drag and drop a file here, or click to select one": "拖放文件到该区域或点击选择文件", 88 | "Port forwarding": "端口映射", 89 | "Test port": "端口测试", 90 | "Port test successfully.": "端口测试成功", 91 | "Port": "端口", 92 | "Client": "客户端", 93 | "Flags": "标志", 94 | "Progress": "进度", 95 | "Download": "下载速度", 96 | "Upload": "上传速度", 97 | "Enable Local Peer Discovery": "启用本地节点发现", 98 | "Enable DHT": "启用 DHT", 99 | "Enable Peer Exchange": "启用节点交换", 100 | "Enable uTP": "启用 uTP", 101 | "Disk Settings": "磁盘设置", 102 | "Disk Cache Size": "磁盘缓存大小", 103 | "Protocol Settings": "协议设置", 104 | "Encryption Settings": "加密设置", 105 | "Encryption Options": "加密选项", 106 | "Preferred Encryption": "优先加密", 107 | "Required Encryption": "强制加密", 108 | "Tolerated Encryption": "允许加密", 109 | "Queue": "队列", 110 | "Queue Settings": "队列设置", 111 | "Consider idle torrents as stalled after": "种子卡住多久后判断为无响应", 112 | "minutes": "分钟", 113 | "Download Queue": "下载队列大小", 114 | "Upload Queue": "上传队列大小", 115 | "Seeding Limits": "做种限制", 116 | "Seed ratio limit": "分享率限制", 117 | "Idle seeding limit": "多久无活动后停止做种", 118 | "Transmission Version": "Transmission 版本", 119 | "UI Version": "UI 版本", 120 | "RPC Version": "RPC 版本", 121 | "Torrent added successfully": "种子添加成功", 122 | "Torrent deleted successfully": "种子删除成功", 123 | "Torrent started successfully": "种子开始成功", 124 | "Torrent stopped successfully": "种子停止成功", 125 | "Torrent path renamed successfully": "种子路径重命名成功", 126 | "Torrent location set successfully": "种子数据保存路径设置成功", 127 | "Port test successfully": "端口测试成功", 128 | "Failed to test port": "端口测试失败", 129 | "Failed to add torrent": "添加种子失败", 130 | "Failed to delete torrent": "删除种子失败", 131 | "Failed to start torrent": "启动种子失败", 132 | "Failed to stop torrent": "停止种子失败", 133 | "Failed to rename torrent path": "重命名种子路径失败", 134 | "Failed to set torrent location": "设置种子位置失败", 135 | "Session setting saved successfully": "会话设置成功保存", 136 | "Failed to save session setting": "保存会话设置失败", 137 | "Are you sure you want to do this?": "你确定要执行此操作吗?", 138 | "The following torrents will be deleted": "以下种子将被删除", 139 | "Delete data": "同时删除数据", 140 | "Move data": "同时移动数据", 141 | "Last Announce": "最后更新状态", 142 | "Announce Time": "最后更新时间", 143 | "Seeders": "做种数", 144 | "Leechers": "下载数", 145 | "Success": "成功", 146 | "Active": "活跃中", 147 | "h": "小时", 148 | "m": "分钟", 149 | "s": "秒", 150 | "eta": "剩余时间", 151 | "Search ...": "搜索 ...", 152 | "EditingFollowingTorrent": "正在编辑该种子", 153 | "UI Settings": "UI 设置", 154 | "Client Network Speed Summary": "本地网速聚合展示", 155 | "UI Settings updated": "UI 设置已更新", 156 | "Clear filters" : "清除筛选", 157 | "Labels": "标签" 158 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | import './lib/i18n.ts' 6 | import {HashRouter} from "react-router-dom"; 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | 13 | , 14 | ) 15 | -------------------------------------------------------------------------------- /src/schemas/torrentSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const schema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | totalSize: z.string(), 7 | percentDone: z.number(), 8 | status: z.number(), 9 | rateDownload: z.number(), 10 | rateUpload: z.number(), 11 | uploadRatio: z.number(), 12 | uploadedEver: z.number(), 13 | peersGettingFromUs: z.number(), 14 | downloadDir: z.string(), 15 | addedDate: z.number(), 16 | error: z.number(), 17 | eta: z.number(), 18 | errorString: z.string(), 19 | peersSendingToUs: z.number(), 20 | labels: z.array(z.string()), 21 | trackerStats: z.array(z.object({ 22 | host: z.string(), 23 | seederCount: z.number(), 24 | leecherCount: z.number(), 25 | })), 26 | }) 27 | 28 | export type torrentSchema = z.infer; -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": [ 6 | "./src/*" 7 | ] 8 | }, 9 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 10 | "target": "ES2020", 11 | "useDefineForClassFields": true, 12 | "lib": [ 13 | "ES2020", 14 | "DOM", 15 | "DOM.Iterable" 16 | ], 17 | "module": "ESNext", 18 | "skipLibCheck": true, 19 | /* Bundler mode */ 20 | "moduleResolution": "bundler", 21 | "allowImportingTsExtensions": true, 22 | "isolatedModules": true, 23 | "moduleDetection": "force", 24 | "noEmit": true, 25 | "jsx": "react-jsx", 26 | /* Linting */ 27 | "strict": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noFallthroughCasesInSwitch": true, 31 | "noUncheckedSideEffectImports": true 32 | }, 33 | "include": [ 34 | "src" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "forceConsistentCasingInFileNames": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": [ 16 | "./src/*" 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from "path" 4 | import tailwindcss from "@tailwindcss/vite" 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | base: "/transmission/web/", 9 | plugins: [react(), tailwindcss()], 10 | resolve: { 11 | alias: { 12 | "@": path.resolve(__dirname, "./src"), 13 | }, 14 | } 15 | }) 16 | --------------------------------------------------------------------------------