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