More stuff

This commit is contained in:
niku 2023-05-28 14:28:05 +02:00
parent 13670d0770
commit d7ae269d46
24 changed files with 790 additions and 113 deletions

4
.env Normal file
View File

@ -0,0 +1,4 @@
VITE_DEFAULT_CLIENT_URL=
VITE_DEFAULT_CLIENT_USER=
VITE_DEFAULT_CLIENT_PASS=
VITE_DEFAULT_TORRENT_ADD=

View File

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

222
pnpm-lock.yaml generated
View File

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

View File

@ -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 <Login />;
}
return <Home />;
}

BIN
src/assets/login-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -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 (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Login into Transmission</CardTitle>
<CardDescription>
Enter your local or remote credentials.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="instance">Instance</Label>
<Input
id="instance"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={user}
onChange={(e) => setUser(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={submitLogin}>
Login
</Button>
</CardFooter>
</Card>
);
}

View File

@ -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 (
<div className={cn("flex flex-col h-screen", className)}>
<div className="space-y-4 py-4">
<div className="px-4 py-2">
<h2 className="mb-2 px-2 text-lg font-semibold tracking-tight">
Status
</h2>
<div className="space-y-1">
<Button
variant="secondary"
size="sm"
className="w-full justify-start"
>
<AlignJustify className="mr-2 h-4 w-4" />
All
</Button>
<Button variant="ghost" size="sm" className="w-full justify-start">
<FileDown className="mr-2 h-4 w-4" />
Downloading
</Button>
<Button variant="ghost" size="sm" className="w-full justify-start">
<CheckCircle className="mr-2 h-4 w-4" />
Completed
</Button>
<Button variant="ghost" size="sm" className="w-full justify-start">
<PlayCircle className="mr-2 h-4 w-4" />
Active
</Button>
<Button variant="ghost" size="sm" className="w-full justify-start">
<PauseCircle className="mr-2 h-4 w-4" />
Inactive
</Button>
</div>
</div>
</div>
<div className="flex-1 " />
<div className="space-y-4 py-4">
<div className="px-4 py-2">
<Card>
<CardHeader className="py-3">
<CardDescription>Version: {session.version}</CardDescription>
</CardHeader>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import prettyBytes from "pretty-bytes";
const messages = new Map<number, string>([
[0, "Stopped"],
[1, "Verifying"],
[2, "Verifying"],
[3, "Downloading"],
[4, "Downloading"],
[5, "Seeding"],
[6, "Seeding"],
]);
export function TorrentStatus({ state }: { state: number }) {
return <span>{messages.get(state) ?? "Unknown state"}</span>;
}
export function TorrentSize({ sizeInBytes }: { sizeInBytes: number }) {
return <span>{prettyBytes(sizeInBytes)}</span>;
}
export function TorrentSpeed({ speedInBytes }: { speedInBytes: number }) {
return <span>{prettyBytes(speedInBytes ?? 0)}/s</span>;
}

View File

@ -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<Torrent>[] = [
@ -19,18 +44,29 @@ export const columns: ColumnDef<Torrent>[] = [
{
accessorKey: "sizeWhenDone",
header: "Size",
cell: (props) => <TorrentSize sizeInBytes={props.getValue<number>()} />,
},
{
accessorKey: "percentDone",
header: "Progress",
cell: (props) => <Progress value={props.getValue<number>() * 100} />,
},
{
accessorKey: "status",
header: "Status",
cell: (props) => (
<TorrentStatus key={props.cell.id} state={props.getValue<number>()} />
),
},
{
accessorKey: "rateDownload (B/s)",
header: "Download",
cell: (props) => <TorrentSpeed speedInBytes={props.getValue<number>()} />,
},
{
accessorKey: "rateUpload (B/s)",
header: "Upload",
cell: (props) => <TorrentSpeed speedInBytes={props.getValue<number>()} />,
},
{
accessorKey: "eta",

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(" flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -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<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,24 @@
import {
Credentials,
RpcCall,
RpcResponse,
SessionGetCmd,
} from "@/models/transmission";
import { createContext } from "react";
interface TransmissionState {
isLoggedIn: boolean;
session: SessionGetCmd["response"];
call: <T extends RpcCall>(
method: string,
args: T["arguments"]
) => Promise<RpcResponse<T["response"]>>;
login: (credentials: Credentials) => Promise<void>;
}
export const TransmissionContext = createContext<TransmissionState>({
isLoggedIn: false,
session: { version: "" },
call: () => Promise.resolve({ arguments: {}, result: "" }),
login: () => Promise.resolve(),
});

View File

@ -10,5 +10,5 @@ export function useInterval(callback: () => void, delay: number) {
useEffect(() => {
const id = setInterval(() => ref.current(), delay);
return () => clearInterval(id);
}, []);
}, [delay]);
}

View File

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

1
src/lib/env.ts Normal file
View File

@ -0,0 +1 @@
export const env = import.meta.env;

View File

@ -1,51 +0,0 @@
interface RpcMessage<T> {
arguments: T;
method: string;
tag: number;
}
interface RpcResponse<T> {
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<T extends RpcCall>(
method: string,
args: T["arguments"]
): Promise<RpcResponse<T["response"]>> {
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<T["arguments"]>),
});
if (resp.status === 409) {
this.csrfToken = resp.headers.get("X-Transmission-Session-Id") ?? "";
return this.call(method, args);
}
const data: RpcResponse<T["response"]> = await resp.json();
return data;
}
}

View File

@ -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(
<React.StrictMode>
<TransmissionProvider>
<App />
</TransmissionProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,29 @@
export interface RpcMessage<T> {
arguments: T;
method: string;
}
export interface RpcResponse<T> {
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;
};
}

View File

@ -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<Torrent[]>([]);
useInterval(async () => {
const resp = await client.call<TorrentGetCmd>("torrent-get", {
fields: [
"id",
"name",
"sizeWhenDone",
"status",
"rateDownload (B/s)",
"rateUpload (B/s)",
"eta",
"uploadRatio",
],
fields: fields,
});
setTableData(resp.arguments.torrents);
}, 10000);
@ -46,8 +37,9 @@ function Home() {
};
return (
<div>
<DataTable columns={columns} data={tableData} />
<div className="flex">
<Sidebar className="w-64" />
<div className="flex-1 container">
<div className="flex">
<Input
type="url"
@ -59,6 +51,8 @@ function Home() {
Download
</Button>
</div>
<DataTable columns={columns} data={tableData} />
</div>
</div>
);
}

17
src/pages/login.tsx Normal file
View File

@ -0,0 +1,17 @@
import { LoginCard } from "@/components/login";
import image from "@/assets/login-bg.jpg";
function Login() {
return (
<div
className="w-screen h-screen flex justify-center items-center"
style={{ backgroundImage: `url(${image})` }}
>
<div className="w-1/5">
<LoginCard />
</div>
</div>
);
}
export default Login;

View File

@ -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<SessionGetCmd["response"]>({
version: "",
});
const [credentials, setCredentials] = useState<Credentials>({
url: "",
user: "",
password: "",
});
const [csrfToken, setCsrfToken] = useState<string>("");
useEffect(() => {
const inner = async () => {
if (isLoggedIn === true) return;
const resp = await _call<SessionGetCmd["arguments"]>("session-get", {
fields: ["version"],
});
if (resp.status === 401) {
setSession({ version: "" });
setIsLoggedIn(false);
return;
}
const data: RpcResponse<SessionGetCmd["response"]> = await resp.json();
setSession(data.arguments);
setIsLoggedIn(true);
};
inner();
}, [credentials, isLoggedIn]);
const _call = async <T extends object>(
method: string,
args: T,
_csrfToken?: string
): Promise<Response> => {
_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<T>),
});
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 <T extends RpcCall>(
method: string,
args: T["arguments"]
): Promise<RpcResponse<T["response"]>> => {
const resp = await _call<T["arguments"]>(method, args);
const data: RpcResponse<T["response"]> = await resp.json();
return data;
};
const login = async (credentials: Credentials) => {
setCredentials(credentials);
};
return (
<TransmissionContext.Provider value={{ login, call, isLoggedIn, session }}>
{props.children}
</TransmissionContext.Provider>
);
}

View File

@ -34,6 +34,8 @@
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
--font-sans: Roboto;
}
.dark {