diff --git a/.env b/.env new file mode 100644 index 0000000..47b147f --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +VITE_DEFAULT_CLIENT_URL= +VITE_DEFAULT_CLIENT_USER= +VITE_DEFAULT_CLIENT_PASS= +VITE_DEFAULT_TORRENT_ADD= diff --git a/package.json b/package.json index f9899e5..2ae33d9 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,20 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.4", "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-query": "^4.29.7", "@tanstack/react-table": "^8.9.1", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", - "immer": "^10.0.2", "lucide-react": "^0.221.0", + "pretty-bytes": "^6.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "tailwind-merge": "^1.12.0", - "tailwindcss-animate": "^1.0.5", - "zustand": "^4.3.8" + "tailwindcss-animate": "^1.0.5" }, "devDependencies": { "@types/node": "^20.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0da31f5..d0a7f9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,9 @@ lockfileVersion: 5.4 specifiers: + '@radix-ui/react-label': ^2.0.2 + '@radix-ui/react-progress': ^1.0.3 + '@radix-ui/react-scroll-area': ^1.0.4 '@radix-ui/react-slot': ^1.0.2 '@tanstack/react-query': ^4.29.7 '@tanstack/react-table': ^8.9.1 @@ -16,9 +19,9 @@ specifiers: eslint: ^8.38.0 eslint-plugin-react-hooks: ^4.6.0 eslint-plugin-react-refresh: ^0.3.4 - immer: ^10.0.2 lucide-react: ^0.221.0 postcss: ^8.4.23 + pretty-bytes: ^6.1.0 react: ^18.2.0 react-dom: ^18.2.0 tailwind-merge: ^1.12.0 @@ -26,21 +29,22 @@ specifiers: tailwindcss-animate: ^1.0.5 typescript: ^5.0.2 vite: ^4.3.2 - zustand: ^4.3.8 dependencies: + '@radix-ui/react-label': 2.0.2_h73yytdk5ntplvft5jzh7k37ce + '@radix-ui/react-progress': 1.0.3_h73yytdk5ntplvft5jzh7k37ce + '@radix-ui/react-scroll-area': 1.0.4_h73yytdk5ntplvft5jzh7k37ce '@radix-ui/react-slot': 1.0.2_bfoz4c5kom3f237nig75ykjhhy '@tanstack/react-query': 4.29.7_biqbaboplfbrettd7655fr4n2y '@tanstack/react-table': 8.9.1_biqbaboplfbrettd7655fr4n2y class-variance-authority: 0.6.0_typescript@5.0.4 clsx: 1.2.1 - immer: 10.0.2 lucide-react: 0.221.0_react@18.2.0 + pretty-bytes: 6.1.0 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 tailwind-merge: 1.12.0 tailwindcss-animate: 1.0.5_tailwindcss@3.3.2 - zustand: 4.3.8_immer@10.0.2+react@18.2.0 devDependencies: '@types/node': 20.2.4 @@ -600,6 +604,18 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 + /@radix-ui/number/1.0.1: + resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} + dependencies: + '@babel/runtime': 7.22.0 + dev: false + + /@radix-ui/primitive/1.0.1: + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + dependencies: + '@babel/runtime': 7.22.0 + dev: false + /@radix-ui/react-compose-refs/1.0.1_bfoz4c5kom3f237nig75ykjhhy: resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} peerDependencies: @@ -614,6 +630,149 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-context/1.0.1_bfoz4c5kom3f237nig75ykjhhy: + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@types/react': 18.2.7 + react: 18.2.0 + dev: false + + /@radix-ui/react-direction/1.0.1_bfoz4c5kom3f237nig75ykjhhy: + resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@types/react': 18.2.7 + react: 18.2.0 + dev: false + + /@radix-ui/react-label/2.0.2_h73yytdk5ntplvft5jzh7k37ce: + resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@radix-ui/react-primitive': 1.0.3_h73yytdk5ntplvft5jzh7k37ce + '@types/react': 18.2.7 + '@types/react-dom': 18.2.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + + /@radix-ui/react-presence/1.0.1_h73yytdk5ntplvft5jzh7k37ce: + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@radix-ui/react-compose-refs': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-use-layout-effect': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@types/react': 18.2.7 + '@types/react-dom': 18.2.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + + /@radix-ui/react-primitive/1.0.3_h73yytdk5ntplvft5jzh7k37ce: + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@radix-ui/react-slot': 1.0.2_bfoz4c5kom3f237nig75ykjhhy + '@types/react': 18.2.7 + '@types/react-dom': 18.2.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + + /@radix-ui/react-progress/1.0.3_h73yytdk5ntplvft5jzh7k37ce: + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@radix-ui/react-context': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-primitive': 1.0.3_h73yytdk5ntplvft5jzh7k37ce + '@types/react': 18.2.7 + '@types/react-dom': 18.2.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + + /@radix-ui/react-scroll-area/1.0.4_h73yytdk5ntplvft5jzh7k37ce: + resolution: {integrity: sha512-OIClwBkwPG+FKvC4OMTRaa/3cfD069nkKFFL/TQzRzaO42Ce5ivKU9VMKgT7UU6UIkjcQqKBrDOIzWtPGw6e6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-context': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-direction': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-presence': 1.0.1_h73yytdk5ntplvft5jzh7k37ce + '@radix-ui/react-primitive': 1.0.3_h73yytdk5ntplvft5jzh7k37ce + '@radix-ui/react-use-callback-ref': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@radix-ui/react-use-layout-effect': 1.0.1_bfoz4c5kom3f237nig75ykjhhy + '@types/react': 18.2.7 + '@types/react-dom': 18.2.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + /@radix-ui/react-slot/1.0.2_bfoz4c5kom3f237nig75ykjhhy: resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -629,6 +788,34 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-callback-ref/1.0.1_bfoz4c5kom3f237nig75ykjhhy: + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@types/react': 18.2.7 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-layout-effect/1.0.1_bfoz4c5kom3f237nig75ykjhhy: + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.0 + '@types/react': 18.2.7 + react: 18.2.0 + dev: false + /@tanstack/query-core/4.29.7: resolution: {integrity: sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==} dev: false @@ -683,7 +870,6 @@ packages: resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==} dependencies: '@types/react': 18.2.7 - dev: true /@types/react/18.2.7: resolution: {integrity: sha512-ojrXpSH2XFCmHm7Jy3q44nXDyN54+EYKP2lBhJ2bqfyPj6cIUW/FZW/Csdia34NQgq7KYcAlHi5184m4X88+yw==} @@ -1441,10 +1627,6 @@ packages: engines: {node: '>= 4'} dev: true - /immer/10.0.2: - resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==} - dev: false - /import-fresh/3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -1793,6 +1975,11 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /pretty-bytes/6.1.0: + resolution: {integrity: sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + /punycode/2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} @@ -2154,20 +2341,3 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true - - /zustand/4.3.8_immer@10.0.2+react@18.2.0: - resolution: {integrity: sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==} - engines: {node: '>=12.7.0'} - peerDependencies: - immer: '>=9.0' - react: '>=16.8' - peerDependenciesMeta: - immer: - optional: true - react: - optional: true - dependencies: - immer: 10.0.2 - react: 18.2.0 - use-sync-external-store: 1.2.0_react@18.2.0 - dev: false diff --git a/src/App.tsx b/src/App.tsx index 329e52f..b8b4c87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,15 @@ -import Home from "./pages/home"; +import { useTransmission } from "@/hooks/use-transmission"; +import Home from "@/pages/home"; +import Login from "@/pages/login"; function App() { + const { isLoggedIn } = useTransmission(); + console.log(import.meta.env.HELLO); + + if (isLoggedIn === false) { + return ; + } + return ; } diff --git a/src/assets/login-bg.jpg b/src/assets/login-bg.jpg new file mode 100644 index 0000000..10ca1c7 Binary files /dev/null and b/src/assets/login-bg.jpg differ diff --git a/src/components/login/index.tsx b/src/components/login/index.tsx new file mode 100644 index 0000000..051751c --- /dev/null +++ b/src/components/login/index.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTransmission } from "@/hooks/use-transmission"; +import { env } from "@/lib/env"; +import { useState } from "react"; + +const defaultUrl = new URL("/transmission/rpc", window.location.origin); + +export function LoginCard() { + const client = useTransmission(); + + const [url, setUrl] = useState(env.VITE_DEFAULT_CLIENT_URL ?? defaultUrl); + const [user, setUser] = useState(env.VITE_DEFAULT_CLIENT_USER ?? ""); + const [password, setPassword] = useState(env.VITE_DEFAULT_CLIENT_PASS ?? ""); + + const submitLogin = async () => await client.login({ url, user, password }); + + return ( + + + Login into Transmission + + Enter your local or remote credentials. + + + +
+ + setUrl(e.target.value)} + /> +
+
+ + setUser(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + + +
+ ); +} diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx new file mode 100644 index 0000000..5c9fdc2 --- /dev/null +++ b/src/components/sidebar/index.tsx @@ -0,0 +1,67 @@ +import { + AlignJustify, + CheckCircle, + FileDown, + PauseCircle, + PlayCircle, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardDescription } from "../ui/card"; +import { useTransmission } from "@/hooks/use-transmission"; + +interface SidebarProps { + className: string; +} + +export function Sidebar({ className }: SidebarProps) { + const { session } = useTransmission(); + return ( +
+
+
+

+ Status +

+
+ + + + + +
+
+
+
+
+
+ + + Version: {session.version} + + +
+
+
+ ); +} diff --git a/src/components/torrents/cells.tsx b/src/components/torrents/cells.tsx new file mode 100644 index 0000000..5ca0493 --- /dev/null +++ b/src/components/torrents/cells.tsx @@ -0,0 +1,23 @@ +import prettyBytes from "pretty-bytes"; + +const messages = new Map([ + [0, "Stopped"], + [1, "Verifying"], + [2, "Verifying"], + [3, "Downloading"], + [4, "Downloading"], + [5, "Seeding"], + [6, "Seeding"], +]); + +export function TorrentStatus({ state }: { state: number }) { + return {messages.get(state) ?? "Unknown state"}; +} + +export function TorrentSize({ sizeInBytes }: { sizeInBytes: number }) { + return {prettyBytes(sizeInBytes)}; +} + +export function TorrentSpeed({ speedInBytes }: { speedInBytes: number }) { + return {prettyBytes(speedInBytes ?? 0)}/s; +} diff --git a/src/components/torrents/columns.tsx b/src/components/torrents/columns.tsx index b2b2f3a..4476799 100644 --- a/src/components/torrents/columns.tsx +++ b/src/components/torrents/columns.tsx @@ -1,4 +1,25 @@ import { ColumnDef } from "@tanstack/react-table"; +import { + TorrentSize, + TorrentSpeed, + TorrentStatus, +} from "@/components/torrents/cells"; +import { Progress } from "@/components/ui/progress"; + +export const fields = [ + "id", + "name", + "sizeWhenDone", + "status", + "rateDownload (B/s)", + "rateUpload (B/s)", + "eta", + "uploadRatio", + "percentDone", + "magnetLink", + "group", + "labels", +]; export interface Torrent { id: number; @@ -9,6 +30,10 @@ export interface Torrent { "rateUpload (B/s)": number; eta: number; uploadRatio: number; + percentDone: number; + magnetLink: string; + group: string; + labels: string[]; } export const columns: ColumnDef[] = [ @@ -19,18 +44,29 @@ export const columns: ColumnDef[] = [ { accessorKey: "sizeWhenDone", header: "Size", + cell: (props) => ()} />, + }, + { + accessorKey: "percentDone", + header: "Progress", + cell: (props) => () * 100} />, }, { accessorKey: "status", header: "Status", + cell: (props) => ( + ()} /> + ), }, { accessorKey: "rateDownload (B/s)", header: "Download", + cell: (props) => ()} />, }, { accessorKey: "rateUpload (B/s)", header: "Upload", + cell: (props) => ()} />, }, { accessorKey: "eta", diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..dff04b6 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..5c87ea4 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..54b87cd --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/contexts/transmission.ts b/src/contexts/transmission.ts new file mode 100644 index 0000000..d31157c --- /dev/null +++ b/src/contexts/transmission.ts @@ -0,0 +1,24 @@ +import { + Credentials, + RpcCall, + RpcResponse, + SessionGetCmd, +} from "@/models/transmission"; +import { createContext } from "react"; + +interface TransmissionState { + isLoggedIn: boolean; + session: SessionGetCmd["response"]; + call: ( + method: string, + args: T["arguments"] + ) => Promise>; + login: (credentials: Credentials) => Promise; +} + +export const TransmissionContext = createContext({ + isLoggedIn: false, + session: { version: "" }, + call: () => Promise.resolve({ arguments: {}, result: "" }), + login: () => Promise.resolve(), +}); diff --git a/src/hooks/use-interval.ts b/src/hooks/use-interval.ts index 768dfab..9012e0f 100644 --- a/src/hooks/use-interval.ts +++ b/src/hooks/use-interval.ts @@ -10,5 +10,5 @@ export function useInterval(callback: () => void, delay: number) { useEffect(() => { const id = setInterval(() => ref.current(), delay); return () => clearInterval(id); - }, []); + }, [delay]); } diff --git a/src/hooks/use-transmission.ts b/src/hooks/use-transmission.ts index 79c685d..ba5e1b2 100644 --- a/src/hooks/use-transmission.ts +++ b/src/hooks/use-transmission.ts @@ -1,7 +1,7 @@ -import { TransmissionClient } from "@/lib/transmission"; -import { useRef } from "react"; +import { TransmissionContext } from "@/contexts/transmission"; +import { useContext } from "react"; export function useTransmission() { - const ref = useRef(new TransmissionClient()); - return ref.current; + const context = useContext(TransmissionContext); + return context; } diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..2bc9abd --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1 @@ +export const env = import.meta.env; diff --git a/src/lib/transmission.ts b/src/lib/transmission.ts deleted file mode 100644 index e750763..0000000 --- a/src/lib/transmission.ts +++ /dev/null @@ -1,51 +0,0 @@ -interface RpcMessage { - arguments: T; - method: string; - tag: number; -} - -interface RpcResponse { - arguments: T; - result: string; - tag: number; -} - -interface RpcCall { - arguments: object; - response: object; -} - -export class TransmissionClient { - private readonly url = "http://localhost:9091/transmission/rpc"; - private counter = 0; - private credentials: [string, string] = ["user", "password"]; - private csrfToken = ""; - - public async call( - method: string, - args: T["arguments"] - ): Promise> { - const resp = await fetch(this.url, { - method: "POST", - headers: { - Authorization: `Basic ${btoa(this.credentials.join(":"))}`, - "X-Transmission-Session-Id": this.csrfToken, - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - arguments: args, - method: method, - tag: this.counter++, - } as RpcMessage), - }); - - if (resp.status === 409) { - this.csrfToken = resp.headers.get("X-Transmission-Session-Id") ?? ""; - return this.call(method, args); - } - - const data: RpcResponse = await resp.json(); - return data; - } -} diff --git a/src/main.tsx b/src/main.tsx index 9e02e83..362083a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "@/styles/globals.css"; +import { TransmissionProvider } from "@/providers/transmission"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + ); diff --git a/src/models/transmission.ts b/src/models/transmission.ts new file mode 100644 index 0000000..54df504 --- /dev/null +++ b/src/models/transmission.ts @@ -0,0 +1,29 @@ +export interface RpcMessage { + arguments: T; + method: string; +} + +export interface RpcResponse { + arguments: T; + result: string; +} + +export interface RpcCall { + arguments: object; + response: object; +} + +export interface Credentials { + url: string; + user: string; + password: string; +} + +export interface SessionGetCmd { + arguments: { + fields: string[]; + }; + response: { + version: string; + }; +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 381ce09..ef0de0c 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,10 +1,12 @@ -import { Torrent, columns } from "@/components/torrents/columns"; +import { Torrent, columns, fields } from "@/components/torrents/columns"; import { DataTable } from "@/components/torrents/data-table"; import { useTransmission } from "@/hooks/use-transmission"; import { useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useInterval } from "@/hooks/use-interval"; +import { Sidebar } from "@/components/sidebar"; +import { env } from "@/lib/env"; interface TorrentGetCmd { arguments: { fields: string[] }; @@ -18,23 +20,12 @@ interface TorrentAddCmd { function Home() { const client = useTransmission(); - const [urlInput, setUrlInput] = useState( - "https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/debian-11.7.0-amd64-netinst.iso.torrent" - ); + const [urlInput, setUrlInput] = useState(env.VITE_DEFAULT_TORRENT_ADD ?? ""); const [tableData, setTableData] = useState([]); useInterval(async () => { const resp = await client.call("torrent-get", { - fields: [ - "id", - "name", - "sizeWhenDone", - "status", - "rateDownload (B/s)", - "rateUpload (B/s)", - "eta", - "uploadRatio", - ], + fields: fields, }); setTableData(resp.arguments.torrents); }, 10000); @@ -46,18 +37,21 @@ function Home() { }; return ( -
- -
- setUrlInput(e.target.value)} - /> - +
+ +
+
+ setUrlInput(e.target.value)} + /> + +
+
); diff --git a/src/pages/login.tsx b/src/pages/login.tsx new file mode 100644 index 0000000..2e517a3 --- /dev/null +++ b/src/pages/login.tsx @@ -0,0 +1,17 @@ +import { LoginCard } from "@/components/login"; +import image from "@/assets/login-bg.jpg"; + +function Login() { + return ( +
+
+ +
+
+ ); +} + +export default Login; diff --git a/src/providers/transmission.tsx b/src/providers/transmission.tsx new file mode 100644 index 0000000..b78dca1 --- /dev/null +++ b/src/providers/transmission.tsx @@ -0,0 +1,93 @@ +import { PropsWithChildren, useEffect, useState } from "react"; + +import { + Credentials, + RpcCall, + RpcMessage, + RpcResponse, + SessionGetCmd, +} from "@/models/transmission"; +import { TransmissionContext } from "@/contexts/transmission"; + +export function TransmissionProvider(props: PropsWithChildren) { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [session, setSession] = useState({ + version: "", + }); + const [credentials, setCredentials] = useState({ + url: "", + user: "", + password: "", + }); + const [csrfToken, setCsrfToken] = useState(""); + + useEffect(() => { + const inner = async () => { + if (isLoggedIn === true) return; + const resp = await _call("session-get", { + fields: ["version"], + }); + + if (resp.status === 401) { + setSession({ version: "" }); + setIsLoggedIn(false); + return; + } + + const data: RpcResponse = await resp.json(); + setSession(data.arguments); + setIsLoggedIn(true); + }; + + inner(); + }, [credentials, isLoggedIn]); + + const _call = async ( + method: string, + args: T, + _csrfToken?: string + ): Promise => { + _csrfToken ??= csrfToken; + const authToken = btoa(`${credentials.user}:${credentials.password}`); + const resp = await fetch(credentials.url, { + method: "POST", + headers: { + Authorization: `Basic ${authToken}`, + "X-Transmission-Session-Id": _csrfToken, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + arguments: args, + method: method, + } as RpcMessage), + }); + + if (resp.status === 409) { + _csrfToken = resp.headers.get("X-Transmission-Session-Id") ?? ""; + setCsrfToken(_csrfToken); + return await _call(method, args, _csrfToken); + } + + return resp; + }; + + const call = async ( + method: string, + args: T["arguments"] + ): Promise> => { + const resp = await _call(method, args); + const data: RpcResponse = await resp.json(); + return data; + }; + + const login = async (credentials: Credentials) => { + setCredentials(credentials); + }; + + return ( + + {props.children} + + ); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index a1fb5a3..52a9eb5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -34,6 +34,8 @@ --ring: 215 20.2% 65.1%; --radius: 0.5rem; + + --font-sans: Roboto; } .dark {