transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and 29 web files; no db/ directory, because there are no tables. ── The one thing that is not self-contained ── The connection lives in `service_connections`, a CORE table, reached through getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin declares no schema. Deliberate: that table is the app store's shared credential store, keyed on (user_id, service), already encrypted at rest and already read by app-store/effects.ts. A private copy of the same three columns would be a second place the same fact could live, and the runbook is explicit that a move plus a data-format change gives any failure two causes. The cost, stated rather than hidden: this plugin depends on a core facility it does not own, and would break if that table changed shape. Photos, by contrast, owns photos_config outright. ── 38 palette classes paid down, mapped by meaning ── blue downloading / download speed -> info cyan checking (verifying) -> busy emerald seeding / complete -> success amber queued / stalled / warnings -> warning Collapsing blue and cyan into one tone would have lost a real distinction in the status dot, so `checking` went to `busy` — in-progress work, which is what that tone is for. ── A near-miss worth recording ── The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/ — ten components. tsgo caught it as ten "cannot find module" errors. Worth stating because it is quiet in the other direction: had those been imported lazily, the typecheck would have passed and the panels would have broken at runtime. Check the directory tree, not the file glob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import type { ChangeEvent, DragEvent } 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 () => {
|
||||
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>
|
||||
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={close} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!canSubmit}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add {files.length + urlList.length || ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// torrent-set-location does two unrelated things depending on one boolean, so the dialog asks in words
|
||||
// rather than showing a checkbox called "move".
|
||||
//
|
||||
// move: true — physically relocate the data, then point the torrent at the new place
|
||||
// move: false — the data is ALREADY there; just re-point the torrent (this is how you recover after
|
||||
// moving files by hand, and it is also how you lose a torrent if you pick it by mistake)
|
||||
|
||||
type MoveTorrentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targets: Torrent[];
|
||||
/** Every distinct download dir currently in use, offered as one-click destinations. */
|
||||
knownDirs: string[];
|
||||
};
|
||||
|
||||
export const MoveTorrentDialog = ({ open, onOpenChange, targets, knownDirs }: MoveTorrentDialogProps) => {
|
||||
const { setLocation } = useTorrentMutations();
|
||||
const [location, setLocationValue] = useState('');
|
||||
const [move, setMove] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Seed with the current location when they all share one — the common case is a small edit to it.
|
||||
const dirs = new Set(targets.map((t) => t.downloadDir));
|
||||
setLocationValue(dirs.size === 1 ? [...dirs][0]! : '');
|
||||
setMove(true);
|
||||
}, [open, targets]);
|
||||
|
||||
const submit = async () => {
|
||||
await setLocation.mutateAsync({ ids: targets.map((t) => t.id), location: location.trim(), move });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set location</DialogTitle>
|
||||
<DialogDescription>
|
||||
{targets.length === 1 ? targets[0]!.name : `${targets.length} torrents`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-move-path">Destination</Label>
|
||||
<Input
|
||||
id="tr-move-path"
|
||||
value={location}
|
||||
onChange={(ev) => setLocationValue(ev.target.value)}
|
||||
placeholder="/downloads/complete"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{knownDirs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{knownDirs.map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
onClick={() => setLocationValue(dir)}
|
||||
title={dir}
|
||||
className="max-w-full truncate rounded bg-muted px-2 py-1 text-xs hover:bg-muted/70"
|
||||
>
|
||||
{dir}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<input type="radio" name="tr-move-mode" className="mt-1" checked={move} onChange={() => setMove(true)} />
|
||||
<span>
|
||||
<span className="font-medium">Move the data there</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Transmission copies the files to the new location and removes the originals.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="tr-move-mode"
|
||||
className="mt-1"
|
||||
checked={!move}
|
||||
onChange={() => setMove(false)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">The data is already there</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Nothing is copied. Use this after moving files yourself — if the files are not there, the torrent will
|
||||
re-download.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!location.trim() || setLocation.isPending}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TriangleAlert } from 'lucide-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 { formatSize } from '../format';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Removing a torrent and deleting its data are one RPC apart and a very long way apart in consequence, so
|
||||
// they are one dialog with an explicit opt-in rather than two menu items you can mis-click between.
|
||||
|
||||
type RemoveTorrentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targets: Torrent[];
|
||||
onRemoved?: () => void;
|
||||
};
|
||||
|
||||
export const RemoveTorrentDialog = ({ open, onOpenChange, targets, onRemoved }: RemoveTorrentDialogProps) => {
|
||||
const { remove } = useTorrentMutations();
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
|
||||
// Re-arm on every open: "also delete the files" must never be inherited from the last time.
|
||||
useEffect(() => {
|
||||
if (open) setDeleteData(false);
|
||||
}, [open]);
|
||||
|
||||
const bytes = targets.reduce((sum, t) => sum + (t.totalSize ?? 0), 0);
|
||||
const one = targets.length === 1 ? targets[0] : null;
|
||||
|
||||
const confirm = async () => {
|
||||
await remove.mutateAsync({ ids: targets.map((t) => t.id), deleteLocalData: deleteData });
|
||||
onOpenChange(false);
|
||||
onRemoved?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove {one ? 'torrent' : `${targets.length} torrents`}?</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{one ? one.name : `${targets.length} torrents totalling ${formatSize(bytes)}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={deleteData} onCheckedChange={(v) => setDeleteData(v === true)} />
|
||||
<span>
|
||||
<span className="font-medium">Also delete the downloaded files</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{deleteData
|
||||
? `${formatSize(bytes)} will be deleted from disk. This cannot be undone.`
|
||||
: 'The files stay on disk; only the torrent is removed.'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{deleteData && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" />
|
||||
Transmission deletes the data immediately — there is no trash.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirm} disabled={remove.isPending}>
|
||||
{deleteData ? 'Remove and delete' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// torrent-rename-path renames a path *inside* the torrent, addressed by its full path relative to the
|
||||
// torrent root. Renaming the root itself means passing the torrent's own name as the path — which is why
|
||||
// this dialog takes a `path` as well as the new name, and why it is single-target only.
|
||||
|
||||
type RenameDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
torrent: Torrent | null;
|
||||
/** The path being renamed; the torrent's own name when renaming the root. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const RenameDialog = ({ open, onOpenChange, torrent, path }: RenameDialogProps) => {
|
||||
const { rename } = useTorrentMutations();
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// Only the last segment is editable — the rest of the path is where the file lives, not what it is called.
|
||||
const segments = path.split('/');
|
||||
const parent = segments.slice(0, -1).join('/');
|
||||
const current = segments[segments.length - 1] ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setName(current);
|
||||
}, [open, current]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!torrent) return;
|
||||
await rename.mutateAsync({ id: torrent.id, path, name: name.trim() });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename</DialogTitle>
|
||||
<DialogDescription className="break-all">{parent ? `${parent}/` : path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-rename">New name</Label>
|
||||
<Input
|
||||
id="tr-rename"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => ev.key === 'Enter' && name.trim() && void submit()}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This renames the file or folder on disk, not just the display name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!name.trim() || name.trim() === current || rename.isPending}>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Torrent } from '../shared';
|
||||
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 () => {
|
||||
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>
|
||||
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={set.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Transmission 4 replaced the old trackerAdd/trackerRemove/trackerReplace triple with `trackerList`: the
|
||||
// whole announce list as text, one URL per line, a blank line between tiers. Editing text is also the only
|
||||
// honest way to express tiers, which a list of rows with buttons cannot.
|
||||
|
||||
type TrackersDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; torrent: Torrent | null };
|
||||
|
||||
/** Rebuild the tier-separated text from trackerStats when the daemon didn't send trackerList. */
|
||||
function fromStats(torrent: Torrent): string {
|
||||
const tiers = new Map<number, string[]>();
|
||||
for (const t of torrent.trackerStats ?? []) {
|
||||
const list = tiers.get(t.tier) ?? [];
|
||||
list.push(t.announce);
|
||||
tiers.set(t.tier, list);
|
||||
}
|
||||
return [...tiers.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, urls]) => urls.join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export const TrackersDialog = ({ open, onOpenChange, torrent }: TrackersDialogProps) => {
|
||||
const { set } = useTorrentMutations();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !torrent) return;
|
||||
setText(torrent.trackerList ?? fromStats(torrent));
|
||||
}, [open, torrent]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!torrent) return;
|
||||
// Trailing whitespace on a line makes Transmission reject the whole list, and pasting from a website
|
||||
// reliably brings some along.
|
||||
const normalised = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n');
|
||||
await set.mutateAsync({ ids: [torrent.id], trackerList: normalised });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Trackers</DialogTitle>
|
||||
<DialogDescription className="break-words">{torrent?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-trackers">Announce URLs</Label>
|
||||
<Textarea
|
||||
id="tr-trackers"
|
||||
rows={10}
|
||||
value={text}
|
||||
onChange={(ev) => setText(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URL per line. Leave a blank line between tiers — Transmission only falls through to the next tier when
|
||||
every tracker in the one above it fails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{torrent?.isPrivate && (
|
||||
<p className="rounded-lg bg-warning/15 px-3 py-2 text-xs text-warning">
|
||||
This is a private torrent. Adding trackers it wasn't issued with will not help and may get you banned from
|
||||
the tracker it came from.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={set.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user