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>
191 lines
6.1 KiB
TypeScript
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>
|
|
);
|