Files
transmission/web/dialogs/TorrentOptionsDialog.tsx
T
pastilhasandClaude Opus 5 17edfa9403 real forms across the plugin, so enter submits instead of doing nothing
connection settings, add/move/rename/options dialogs and daemon settings all
put their fields in a div, so the button was the only way to submit and enter
silently did nothing — a keyboard trap wearing a layout. wrapped each in a
<form onSubmit>, made every non-submit button in those forms type="button"
(test port, update blocklist, discard, cancel, remove-file, known-dir
shortcuts), and dropped RenameDialog's manual onKeyDown enter check now that
the form handles it natively. the toolbar's filter box is left alone — it has
no submit action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:52:36 +00:00

253 lines
9.9 KiB
TypeScript

import type { Torrent } from '../shared';
import type { FormEvent } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { LIMIT_MODE, PRIORITY } from '../shared';
import { useTorrentMutations } from '../useTransmissionData';
// Per-torrent bandwidth, seeding and peer options — the reference client's "other settings" dialog.
//
// Every limit here is a pair (a boolean and a number) because Transmission stores them that way: turning a
// limit off keeps the number, so unchecking and re-checking gets you back the value you had.
type TorrentOptionsDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; targets: Torrent[] };
type FormState = {
bandwidthPriority: string;
honorsSessionLimits: boolean;
downloadLimited: boolean;
downloadLimit: string;
uploadLimited: boolean;
uploadLimit: string;
seedRatioMode: string;
seedRatioLimit: string;
seedIdleMode: string;
seedIdleLimit: string;
peerLimit: string;
sequential: boolean;
};
const fromTorrent = (t: Torrent): FormState => ({
bandwidthPriority: String(t.bandwidthPriority ?? 0),
honorsSessionLimits: t.honorsSessionLimits ?? true,
downloadLimited: t.downloadLimited ?? false,
downloadLimit: String(t.downloadLimit ?? 0),
uploadLimited: t.uploadLimited ?? false,
uploadLimit: String(t.uploadLimit ?? 0),
seedRatioMode: String(t.seedRatioMode ?? 0),
seedRatioLimit: String(t.seedRatioLimit ?? 2),
seedIdleMode: String(t.seedIdleMode ?? 0),
seedIdleLimit: String(t.seedIdleLimit ?? 30),
peerLimit: String(t['peer-limit'] ?? 50),
sequential: t.sequential_download ?? false,
});
export const TorrentOptionsDialog = ({ open, onOpenChange, targets }: TorrentOptionsDialogProps) => {
const { set } = useTorrentMutations();
const [form, setForm] = useState<FormState | null>(null);
useEffect(() => {
// Seeded from the first target. With a mixed selection the others' values are simply overwritten —
// which is what "apply these settings to all of them" means, and the header says so.
if (open && targets[0]) setForm(fromTorrent(targets[0]));
}, [open, targets]);
if (!form) return null;
const patch = (next: Partial<FormState>) => setForm((prev) => (prev ? { ...prev, ...next } : prev));
const submit = async (ev?: FormEvent) => {
ev?.preventDefault();
await set.mutateAsync({
ids: targets.map((t) => t.id),
bandwidthPriority: Number(form.bandwidthPriority),
honorsSessionLimits: form.honorsSessionLimits,
downloadLimited: form.downloadLimited,
downloadLimit: Math.max(0, Number(form.downloadLimit) || 0),
uploadLimited: form.uploadLimited,
uploadLimit: Math.max(0, Number(form.uploadLimit) || 0),
seedRatioMode: Number(form.seedRatioMode),
seedRatioLimit: Math.max(0, Number(form.seedRatioLimit) || 0),
seedIdleMode: Number(form.seedIdleMode),
seedIdleLimit: Math.max(0, Number(form.seedIdleLimit) || 0),
'peer-limit': Math.max(1, Number(form.peerLimit) || 1),
sequential_download: form.sequential,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Torrent options</DialogTitle>
<DialogDescription>
{targets.length === 1
? targets[0]!.name
: `Applying to ${targets.length} torrents — all of them will take these values.`}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-5">
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bandwidth</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="tr-opt-priority">Priority</Label>
<Select value={form.bandwidthPriority} onValueChange={(v) => patch({ bandwidthPriority: v })}>
<SelectTrigger id="tr-opt-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(PRIORITY.High)}>High</SelectItem>
<SelectItem value={String(PRIORITY.Normal)}>Normal</SelectItem>
<SelectItem value={String(PRIORITY.Low)}>Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="tr-opt-peers">Maximum peers</Label>
<Input
id="tr-opt-peers"
type="number"
min={1}
value={form.peerLimit}
onChange={(ev) => patch({ peerLimit: ev.target.value })}
/>
</div>
</div>
<LimitRow
id="tr-opt-down"
label="Limit download speed"
enabled={form.downloadLimited}
value={form.downloadLimit}
onToggle={(v) => patch({ downloadLimited: v })}
onChange={(v) => patch({ downloadLimit: v })}
/>
<LimitRow
id="tr-opt-up"
label="Limit upload speed"
enabled={form.uploadLimited}
value={form.uploadLimit}
onToggle={(v) => patch({ uploadLimited: v })}
onChange={(v) => patch({ uploadLimit: v })}
/>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.honorsSessionLimits}
onCheckedChange={(v) => patch({ honorsSessionLimits: v === true })}
/>
Honour the global speed limits
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.sequential} onCheckedChange={(v) => patch({ sequential: v === true })} />
Download pieces in order
</label>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Stop seeding when</h3>
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="tr-opt-ratio-mode">Ratio reaches</Label>
<Select value={form.seedRatioMode} onValueChange={(v) => patch({ seedRatioMode: v })}>
<SelectTrigger id="tr-opt-ratio-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Seed forever</SelectItem>
</SelectContent>
</Select>
</div>
<Input
type="number"
step="0.1"
min={0}
disabled={form.seedRatioMode !== String(LIMIT_MODE.Single)}
value={form.seedRatioLimit}
onChange={(ev) => patch({ seedRatioLimit: ev.target.value })}
/>
</div>
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="tr-opt-idle-mode">Idle for (minutes)</Label>
<Select value={form.seedIdleMode} onValueChange={(v) => patch({ seedIdleMode: v })}>
<SelectTrigger id="tr-opt-idle-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Never stop</SelectItem>
</SelectContent>
</Select>
</div>
<Input
type="number"
min={0}
disabled={form.seedIdleMode !== String(LIMIT_MODE.Single)}
value={form.seedIdleLimit}
onChange={(ev) => patch({ seedIdleLimit: ev.target.value })}
/>
</div>
</section>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={set.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
type LimitRowProps = {
id: string;
label: string;
enabled: boolean;
value: string;
onToggle: (enabled: boolean) => void;
onChange: (value: string) => void;
};
const LimitRow = ({ id, label, enabled, value, onToggle, onChange }: LimitRowProps) => (
<div className="flex items-center gap-3">
<label className="flex flex-1 items-center gap-2 text-sm">
<Checkbox checked={enabled} onCheckedChange={(v) => onToggle(v === true)} />
{label}
</label>
<Input
id={id}
type="number"
min={0}
disabled={!enabled}
value={value}
onChange={(ev) => onChange(ev.target.value)}
className="w-28"
/>
<span className="w-10 text-xs text-muted-foreground">kB/s</span>
</div>
);