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>
264 lines
9.6 KiB
TypeScript
264 lines
9.6 KiB
TypeScript
import type { ChangeEvent, DragEvent, FormEvent } from 'react';
|
|
import { useState } from 'react';
|
|
import { FileUp, Loader2, X } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
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 { Textarea } from '@/components/ui/textarea';
|
|
import { PRIORITY } from '../shared';
|
|
import { useSession, useTorrentMutations } from '../useTransmissionData';
|
|
|
|
// Add by magnet/URL or by .torrent file, one dialog.
|
|
//
|
|
// Transmission's torrent-add takes either `filename` (a magnet or an http URL it fetches itself) or
|
|
// `metainfo` (base64 of the .torrent bytes). It cannot take both, so the two inputs are exclusive here:
|
|
// picking a file clears the text box and vice versa, rather than letting you fill in both and guess.
|
|
|
|
type AddTorrentDialogProps = { open: boolean; onOpenChange: (open: boolean) => void };
|
|
|
|
type PickedFile = { name: string; metainfo: string };
|
|
|
|
/** FileReader gives back a `data:...;base64,XXX` URL; torrent-add wants only the payload. */
|
|
async function readAsBase64(file: File): Promise<string> {
|
|
const buffer = await file.arrayBuffer();
|
|
let binary = '';
|
|
const bytes = new Uint8Array(buffer);
|
|
// Chunked because String.fromCharCode(...bytes) blows the argument limit on a torrent with many pieces.
|
|
for (let i = 0; i < bytes.length; i += 8192) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
export const AddTorrentDialog = ({ open, onOpenChange }: AddTorrentDialogProps) => {
|
|
const { session } = useSession();
|
|
const { add } = useTorrentMutations();
|
|
|
|
const [urls, setUrls] = useState('');
|
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
|
const [downloadDir, setDownloadDir] = useState('');
|
|
const [labels, setLabels] = useState('');
|
|
const [paused, setPaused] = useState(false);
|
|
const [priority, setPriority] = useState('0');
|
|
const [sequential, setSequential] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const defaultDir = (session?.['download-dir'] as string) ?? '';
|
|
|
|
const reset = () => {
|
|
setUrls('');
|
|
setFiles([]);
|
|
setDownloadDir('');
|
|
setLabels('');
|
|
setPaused(false);
|
|
setPriority('0');
|
|
setSequential(false);
|
|
};
|
|
|
|
const close = () => {
|
|
reset();
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const takeFiles = async (list: FileList | null) => {
|
|
if (!list?.length) return;
|
|
const picked = await Promise.all([...list].map(async (f) => ({ name: f.name, metainfo: await readAsBase64(f) })));
|
|
setFiles((prev) => [...prev, ...picked]);
|
|
setUrls('');
|
|
};
|
|
|
|
const onDrop = (ev: DragEvent) => {
|
|
ev.preventDefault();
|
|
setDragging(false);
|
|
void takeFiles(ev.dataTransfer.files);
|
|
};
|
|
|
|
const onPick = (ev: ChangeEvent<HTMLInputElement>) => {
|
|
void takeFiles(ev.target.files);
|
|
// Clear the input so picking the same file twice still fires a change event.
|
|
ev.target.value = '';
|
|
};
|
|
|
|
const urlList = urls
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
const canSubmit = (files.length > 0 || urlList.length > 0) && !busy;
|
|
|
|
const submit = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
const common = {
|
|
downloadDir: downloadDir.trim() || undefined,
|
|
labels: labels
|
|
.split(',')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean),
|
|
paused,
|
|
bandwidthPriority: Number(priority),
|
|
sequentialDownload: sequential,
|
|
};
|
|
|
|
setBusy(true);
|
|
let added = 0;
|
|
let duplicate = 0;
|
|
let failed = 0;
|
|
|
|
// Sequential rather than Promise.all: torrent-add is not idempotent and the daemon serialises it
|
|
// anyway, and a partial failure should report which of a paste of twenty magnets got in.
|
|
for (const file of files) {
|
|
const result = await add.mutateAsync({ ...common, metainfo: file.metainfo }).catch(() => null);
|
|
if (!result) failed += 1;
|
|
else if (result.status === 'duplicate') duplicate += 1;
|
|
else added += 1;
|
|
}
|
|
for (const url of urlList) {
|
|
const result = await add.mutateAsync({ ...common, filename: url }).catch(() => null);
|
|
if (!result) failed += 1;
|
|
else if (result.status === 'duplicate') duplicate += 1;
|
|
else added += 1;
|
|
}
|
|
setBusy(false);
|
|
|
|
const parts = [added && `${added} added`, duplicate && `${duplicate} already present`, failed && `${failed} failed`]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
if (added || duplicate) toast.success(parts);
|
|
else if (failed) toast.error(parts);
|
|
|
|
if (!failed) close();
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Add torrents</DialogTitle>
|
|
<DialogDescription>Paste magnet links or drop .torrent files.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="tr-add-urls">Magnet links or URLs</Label>
|
|
<Textarea
|
|
id="tr-add-urls"
|
|
rows={3}
|
|
value={urls}
|
|
disabled={files.length > 0}
|
|
placeholder={'magnet:?xt=urn:btih:…\nhttps://example.org/file.torrent'}
|
|
onChange={(ev) => setUrls(ev.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">One per line.</p>
|
|
</div>
|
|
|
|
<div
|
|
onDragOver={(ev) => {
|
|
ev.preventDefault();
|
|
setDragging(true);
|
|
}}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={onDrop}
|
|
className={`rounded-lg border border-dashed p-4 text-center transition-colors ${
|
|
dragging ? 'border-primary bg-primary/5' : 'border-border'
|
|
}`}
|
|
>
|
|
<FileUp className="mx-auto h-5 w-5 text-muted-foreground" />
|
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
Drop .torrent files here, or{' '}
|
|
<label className="cursor-pointer text-primary underline">
|
|
browse
|
|
<input type="file" accept=".torrent" multiple className="hidden" onChange={onPick} />
|
|
</label>
|
|
</p>
|
|
{files.length > 0 && (
|
|
<ul className="mt-2 space-y-1 text-left">
|
|
{files.map((f, i) => (
|
|
<li key={`${f.name}-${i}`} className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs">
|
|
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
aria-label={`Remove ${f.name}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label htmlFor="tr-add-dir">Download to</Label>
|
|
<Input
|
|
id="tr-add-dir"
|
|
value={downloadDir}
|
|
placeholder={defaultDir || 'server default'}
|
|
onChange={(ev) => setDownloadDir(ev.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="tr-add-labels">Labels</Label>
|
|
<Input
|
|
id="tr-add-labels"
|
|
value={labels}
|
|
placeholder="comma, separated"
|
|
onChange={(ev) => setLabels(ev.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="tr-add-priority">Priority</Label>
|
|
<Select value={priority} onValueChange={setPriority}>
|
|
<SelectTrigger id="tr-add-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>
|
|
|
|
<div className="flex gap-6">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Checkbox checked={paused} onCheckedChange={(v) => setPaused(v === true)} />
|
|
Start paused
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Checkbox checked={sequential} onCheckedChange={(v) => setSequential(v === true)} />
|
|
Sequential download
|
|
</label>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!canSubmit}>
|
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Add {files.length + urlList.length || ''}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|