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