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,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>
|
||||
);
|
||||
Reference in New Issue
Block a user