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>
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
import type { FormEvent, ReactNode } from 'react';
|
|
import { useState } from 'react';
|
|
import { Loader2, Plug, ShieldCheck } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useMaintenance, useSession } from './useTransmissionData';
|
|
|
|
// The daemon's settings, as one form with a draft.
|
|
//
|
|
// Only the fields you actually changed are sent — session-set is a partial update, and posting the whole
|
|
// object back would race the 5-second poll and re-apply stale values you never touched. The sidecar reads
|
|
// the settings back after writing because Transmission clamps several of them, so the form snaps to what
|
|
// the daemon really holds rather than to what you typed.
|
|
|
|
export const SettingsView = () => {
|
|
const { session, isLoading, save } = useSession();
|
|
const { portTest, updateBlocklist } = useMaintenance();
|
|
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
|
|
|
if (isLoading && !session) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Loading settings…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!session) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
Could not read the session settings.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const get = <T,>(key: string, fallback: T): T =>
|
|
key in draft ? (draft[key] as T) : ((session[key] as T) ?? fallback);
|
|
const set = (key: string, value: unknown) => setDraft((prev) => ({ ...prev, [key]: value }));
|
|
|
|
const dirty = Object.keys(draft).length > 0;
|
|
|
|
const apply = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
await save.mutateAsync(draft);
|
|
setDraft({});
|
|
};
|
|
|
|
const bool = (key: string, label: string, hint?: string) => (
|
|
<Toggle checked={get(key, false)} onChange={(v) => set(key, v)} label={label} hint={hint} />
|
|
);
|
|
|
|
const number = (key: string, label: string, unit?: string, min = 0) => (
|
|
<Field label={label} unit={unit}>
|
|
<Input
|
|
type="number"
|
|
min={min}
|
|
value={String(get(key, 0))}
|
|
onChange={(ev) => set(key, Number(ev.target.value))}
|
|
className="w-32"
|
|
/>
|
|
</Field>
|
|
);
|
|
|
|
const text = (key: string, label: string, placeholder?: string) => (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`tr-${key}`}>{label}</Label>
|
|
<Input
|
|
id={`tr-${key}`}
|
|
value={String(get(key, ''))}
|
|
placeholder={placeholder}
|
|
onChange={(ev) => set(key, ev.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={apply} className="flex h-full min-h-0 flex-col">
|
|
<Tabs defaultValue="download" className="flex min-h-0 flex-1 flex-col">
|
|
<TabsList className="mx-4 mt-3 w-fit shrink-0">
|
|
<TabsTrigger value="download">Downloads</TabsTrigger>
|
|
<TabsTrigger value="bandwidth">Bandwidth</TabsTrigger>
|
|
<TabsTrigger value="network">Network</TabsTrigger>
|
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
|
<TabsTrigger value="other">Other</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
<TabsContent value="download" className="mt-0 space-y-5">
|
|
<Section title="Location">
|
|
{text('download-dir', 'Download directory', '/downloads')}
|
|
{bool('incomplete-dir-enabled', 'Keep incomplete files somewhere else')}
|
|
{get('incomplete-dir-enabled', false) && text('incomplete-dir', 'Incomplete directory')}
|
|
{bool(
|
|
'rename-partial-files',
|
|
'Append .part to incomplete files',
|
|
'Stops media scanners picking up half-downloaded files.',
|
|
)}
|
|
</Section>
|
|
|
|
<Section title="When adding">
|
|
{bool('start-added-torrents', 'Start torrents as soon as they are added')}
|
|
{bool('trash-original-torrent-files', 'Delete the .torrent file after adding')}
|
|
</Section>
|
|
|
|
<Section title="Seeding limits">
|
|
{bool('seedRatioLimited', 'Stop seeding at a ratio')}
|
|
{get('seedRatioLimited', false) && (
|
|
<Field label="Ratio">
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
min={0}
|
|
value={String(get('seedRatioLimit', 2))}
|
|
onChange={(ev) => set('seedRatioLimit', Number(ev.target.value))}
|
|
className="w-32"
|
|
/>
|
|
</Field>
|
|
)}
|
|
{bool('idle-seeding-limit-enabled', 'Stop seeding when idle')}
|
|
{get('idle-seeding-limit-enabled', false) && number('idle-seeding-limit', 'Idle for', 'minutes')}
|
|
</Section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="bandwidth" className="mt-0 space-y-5">
|
|
<Section title="Speed limits">
|
|
{bool('speed-limit-down-enabled', 'Limit download speed')}
|
|
{get('speed-limit-down-enabled', false) && number('speed-limit-down', 'Download', 'kB/s')}
|
|
{bool('speed-limit-up-enabled', 'Limit upload speed')}
|
|
{get('speed-limit-up-enabled', false) && number('speed-limit-up', 'Upload', 'kB/s')}
|
|
</Section>
|
|
|
|
<Section
|
|
title="Alternative speed limits"
|
|
hint="The turtle button in the toolbar switches to these without touching the limits above."
|
|
>
|
|
{bool('alt-speed-enabled', 'Use the alternative limits now')}
|
|
{number('alt-speed-down', 'Download', 'kB/s')}
|
|
{number('alt-speed-up', 'Upload', 'kB/s')}
|
|
{bool('alt-speed-time-enabled', 'Switch automatically on a schedule')}
|
|
{get('alt-speed-time-enabled', false) && (
|
|
<>
|
|
<TimeField
|
|
label="From"
|
|
minutes={get('alt-speed-time-begin', 0)}
|
|
onChange={(v) => set('alt-speed-time-begin', v)}
|
|
/>
|
|
<TimeField
|
|
label="To"
|
|
minutes={get('alt-speed-time-end', 0)}
|
|
onChange={(v) => set('alt-speed-time-end', v)}
|
|
/>
|
|
<DayPicker mask={get('alt-speed-time-day', 127)} onChange={(v) => set('alt-speed-time-day', v)} />
|
|
</>
|
|
)}
|
|
</Section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="network" className="mt-0 space-y-5">
|
|
<Section title="Listening port">
|
|
{number('peer-port', 'Peer port', undefined, 1)}
|
|
{bool('peer-port-random-on-start', 'Pick a random port on each start')}
|
|
{bool('port-forwarding-enabled', 'Ask the router to forward it (UPnP / NAT-PMP)')}
|
|
<div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => portTest.mutate()}
|
|
disabled={portTest.isPending}
|
|
>
|
|
{portTest.isPending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Plug className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Test port
|
|
</Button>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Peers">
|
|
{number('peer-limit-global', 'Maximum peers overall', undefined, 1)}
|
|
{number('peer-limit-per-torrent', 'Maximum peers per torrent', undefined, 1)}
|
|
<Field label="Encryption">
|
|
<Select value={String(get('encryption', 'preferred'))} onValueChange={(v) => set('encryption', v)}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="tolerated">Allow unencrypted</SelectItem>
|
|
<SelectItem value="preferred">Prefer encryption</SelectItem>
|
|
<SelectItem value="required">Require encryption</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
</Section>
|
|
|
|
<Section title="Peer discovery" hint="Private torrents ignore all of these by design.">
|
|
{bool('pex-enabled', 'Peer exchange (PEX)')}
|
|
{bool('dht-enabled', 'Distributed hash table (DHT)')}
|
|
{bool('lpd-enabled', 'Local peer discovery')}
|
|
{bool('utp-enabled', 'µTP — yields to other traffic on your connection')}
|
|
</Section>
|
|
|
|
<Section title="Blocklist">
|
|
{bool('blocklist-enabled', 'Use a blocklist')}
|
|
{text('blocklist-url', 'Blocklist URL')}
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => updateBlocklist.mutate()}
|
|
disabled={updateBlocklist.isPending}
|
|
>
|
|
{updateBlocklist.isPending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Update now
|
|
</Button>
|
|
<span className="text-xs text-muted-foreground">
|
|
{Number(session['blocklist-size'] ?? 0).toLocaleString()} rules loaded
|
|
</span>
|
|
</div>
|
|
</Section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="queue" className="mt-0 space-y-5">
|
|
<Section title="Download queue">
|
|
{bool('download-queue-enabled', 'Limit how many torrents download at once')}
|
|
{get('download-queue-enabled', false) && number('download-queue-size', 'At most', 'torrents', 1)}
|
|
</Section>
|
|
<Section title="Seed queue">
|
|
{bool('seed-queue-enabled', 'Limit how many torrents seed at once')}
|
|
{get('seed-queue-enabled', false) && number('seed-queue-size', 'At most', 'torrents', 1)}
|
|
</Section>
|
|
<Section
|
|
title="Stalled torrents"
|
|
hint="A stalled torrent gives up its queue slot so something else can run."
|
|
>
|
|
{bool('queue-stalled-enabled', 'Treat torrents with no activity as stalled')}
|
|
{get('queue-stalled-enabled', false) && number('queue-stalled-minutes', 'After', 'minutes', 1)}
|
|
</Section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="other" className="mt-0 space-y-5">
|
|
<Section title="Performance">{number('cache-size-mb', 'Disk cache', 'MB')}</Section>
|
|
|
|
<Section title="On completion">
|
|
{bool('script-torrent-done-enabled', 'Run a script when a torrent finishes')}
|
|
{get('script-torrent-done-enabled', false) &&
|
|
text('script-torrent-done-filename', 'Script path', '/etc/transmission/done.sh')}
|
|
</Section>
|
|
|
|
<Section
|
|
title="Default trackers"
|
|
hint="Added to every public torrent. One URL per line, blank line between tiers."
|
|
>
|
|
<Textarea
|
|
rows={6}
|
|
value={String(get('default-trackers', ''))}
|
|
onChange={(ev) => set('default-trackers', ev.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</Section>
|
|
|
|
<Section title="About">
|
|
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
|
<About label="Version" value={String(session.version ?? '—')} />
|
|
<About label="RPC version" value={String(session['rpc-version'] ?? '—')} />
|
|
<About label="Config directory" value={String(session['config-dir'] ?? '—')} />
|
|
<About label="Session ID" value={String(session['session-id'] ?? '—')} />
|
|
</dl>
|
|
</Section>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
|
|
{/* The bar only exists when there is something to save — a permanently-visible disabled Save button
|
|
teaches you to ignore it. */}
|
|
{dirty && (
|
|
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-muted/40 px-4 py-2">
|
|
<span className="flex-1 text-xs text-muted-foreground">
|
|
{Object.keys(draft).length} unsaved change{Object.keys(draft).length === 1 ? '' : 's'}
|
|
</span>
|
|
<Button type="button" size="sm" variant="ghost" onClick={() => setDraft({})}>
|
|
Discard
|
|
</Button>
|
|
<Button type="submit" size="sm" disabled={save.isPending}>
|
|
{save.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const Section = ({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) => (
|
|
<section className="max-w-2xl rounded-xl border border-border p-4">
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
|
{hint && <p className="mt-0.5 text-xs text-muted-foreground">{hint}</p>}
|
|
<div className="mt-3 space-y-3">{children}</div>
|
|
</section>
|
|
);
|
|
|
|
const Toggle = ({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
hint,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (value: boolean) => void;
|
|
label: string;
|
|
hint?: string;
|
|
}) => (
|
|
<label className="flex items-start gap-2.5 text-sm">
|
|
<Checkbox className="mt-0.5" checked={checked} onCheckedChange={(v) => onChange(v === true)} />
|
|
<span>
|
|
{label}
|
|
{hint && <span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>}
|
|
</span>
|
|
</label>
|
|
);
|
|
|
|
const Field = ({ label, unit, children }: { label: string; unit?: string; children: ReactNode }) => (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<span className="w-56 shrink-0">{label}</span>
|
|
{children}
|
|
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
|
</div>
|
|
);
|
|
|
|
/** Transmission stores the alt-speed schedule as minutes past midnight; the browser wants HH:MM. */
|
|
const TimeField = ({
|
|
label,
|
|
minutes,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
minutes: number;
|
|
onChange: (minutes: number) => void;
|
|
}) => {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const value = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
|
return (
|
|
<Field label={label}>
|
|
<Input
|
|
type="time"
|
|
value={value}
|
|
onChange={(ev) => {
|
|
const [h, m] = ev.target.value.split(':').map(Number);
|
|
if (Number.isFinite(h) && Number.isFinite(m)) onChange(h! * 60 + m!);
|
|
}}
|
|
className="w-32"
|
|
/>
|
|
</Field>
|
|
);
|
|
};
|
|
|
|
// alt-speed-time-day is a bitfield, Sunday = bit 0 (rpc-spec.md). 127 is every day.
|
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
const DayPicker = ({ mask, onChange }: { mask: number; onChange: (mask: number) => void }) => (
|
|
<Field label="On days">
|
|
<div className="flex gap-1">
|
|
{DAYS.map((day, i) => {
|
|
const bit = 1 << i;
|
|
const on = (mask & bit) !== 0;
|
|
return (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
onClick={() => onChange(on ? mask & ~bit : mask | bit)}
|
|
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
|
}`}
|
|
>
|
|
{day}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Field>
|
|
);
|
|
|
|
const About = ({ label, value }: { label: string; value: string }) => (
|
|
<div className="flex min-w-0 items-baseline gap-3">
|
|
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
|
<dd className="min-w-0 flex-1 truncate font-mono" title={value}>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|