Files
transmission/web/TorrentToolbar.tsx
pastilhasandClaude Opus 5 864dd16355 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>
2026-08-18 19:19:30 +00:00

191 lines
6.1 KiB
TypeScript

import type { Torrent, TorrentAction } from './shared';
import {
ChevronsDown,
ChevronsUp,
CircleCheckBig,
FolderInput,
Pause,
Play,
Plus,
RefreshCw,
Search,
Settings2,
Rabbit,
Tag,
Trash2,
Turtle,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
// Actions above the list. Everything acts on the current selection except Add and the turtle toggle; with
// nothing selected the selection-scoped buttons are disabled rather than silently acting on all torrents,
// which is the single most destructive mistake a torrent client UI can make.
type TorrentToolbarProps = {
selected: Torrent[];
search: string;
onSearch: (value: string) => void;
onAct: (action: TorrentAction) => void;
onAdd: () => void;
onRemove: () => void;
onMove: () => void;
onLabels: () => void;
onOptions: () => void;
/** Session alt-speed ("turtle mode"), which is global rather than per-torrent. */
altSpeed: boolean;
onToggleAltSpeed: () => void;
total: number;
shown: number;
};
export const TorrentToolbar = ({
selected,
search,
onSearch,
onAct,
onAdd,
onRemove,
onMove,
onLabels,
onOptions,
altSpeed,
onToggleAltSpeed,
total,
shown,
}: TorrentToolbarProps) => {
const none = selected.length === 0;
// "Start" and "Pause" are offered by what the selection can actually do, so a paused selection doesn't
// show a live Pause button that would be a no-op.
const anyStopped = selected.some((t) => t.status === 0);
const anyRunning = selected.some((t) => t.status !== 0);
return (
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
<Button size="sm" onClick={onAdd}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add
</Button>
<Separator />
<IconButton label="Start" icon={Play} disabled={none || !anyStopped} onClick={() => onAct('start')} />
<IconButton label="Pause" icon={Pause} disabled={none || !anyRunning} onClick={() => onAct('stop')} />
<IconButton label="Verify local data" icon={CircleCheckBig} disabled={none} onClick={() => onAct('verify')} />
<IconButton
label="Ask tracker for more peers"
icon={RefreshCw}
disabled={none}
onClick={() => onAct('reannounce')}
/>
<Separator />
<IconButton label="Move up the queue" icon={ChevronsUp} disabled={none} onClick={() => onAct('queue-up')} />
<IconButton label="Move down the queue" icon={ChevronsDown} disabled={none} onClick={() => onAct('queue-down')} />
<Separator />
<IconButton label="Set location" icon={FolderInput} disabled={none} onClick={onMove} />
<IconButton label="Labels" icon={Tag} disabled={none} onClick={onLabels} />
<IconButton label="Torrent options" icon={Settings2} disabled={none} onClick={onOptions} />
<IconButton label="Remove" icon={Trash2} disabled={none} onClick={onRemove} destructive />
<Separator />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="text-xs">
More
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem disabled={none} onSelect={() => onAct('start-now')}>
Start now (skip the queue)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-top')}>
Move to the top of the queue
</DropdownMenuItem>
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-bottom')}>
Move to the bottom of the queue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<IconButton
label={altSpeed ? 'Alternative speed limits are on' : 'Turn on alternative speed limits'}
icon={altSpeed ? Turtle : Rabbit}
onClick={onToggleAltSpeed}
active={altSpeed}
/>
<div className="ml-auto flex items-center gap-2">
{selected.length > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">{selected.length} selected</span>
)}
<span className="text-xs tabular-nums text-muted-foreground">
{shown === total ? `${total}` : `${shown} / ${total}`}
</span>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(ev) => onSearch(ev.target.value)}
placeholder="Filter by name or label"
className="h-8 w-56 pl-7 pr-7 text-xs"
/>
{search && (
<button
type="button"
onClick={() => onSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear filter"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
);
};
const Separator = () => <span className="mx-1 h-5 w-px bg-border" />;
type IconButtonProps = {
label: string;
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
active?: boolean;
};
const IconButton = ({ label, icon: Icon, onClick, disabled, destructive, active }: IconButtonProps) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
disabled={disabled}
onClick={onClick}
aria-label={label}
className={`h-8 w-8 p-0 ${destructive ? 'hover:text-destructive' : ''} ${active ? 'bg-muted text-primary' : ''}`}
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);