Files
transmission/web/TransmissionNav.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

240 lines
8.4 KiB
TypeScript

import type { LucideIcon } from 'lucide-react';
import type { FacetEntry } from './useTorrentFilters';
import { useState } from 'react';
import { NavLink } from 'react-router';
import {
ArrowDownToLine,
ArrowUpFromLine,
ChevronRight,
CircleAlert,
Folder,
Gauge,
ListChecks,
Plug,
Radio,
Settings,
Tag,
Waves,
} from 'lucide-react';
import { TRANSMISSION_SECTIONS, transmissionSectionPath, type TransmissionSectionId } from './shared';
import { formatSpeed } from './format';
import { useSessionStats, useTorrents } from './useTransmissionData';
import { buildFacets, countByStatus, STATUS_FILTERS, useTorrentFilters } from './useTorrentFilters';
// Left panel of /transmission: live throughput on top, the three sections, then the facet filters.
//
// Sections are real links (cmd-click, back button, reload) with active state from react-router's NavLink.
// Filters are buttons because they write to the query string of the page you are already on — they are
// still addressable, just not a different route.
//
// The facet lists are derived from the torrents already in the cache, not fetched separately: Transmission
// has no "list my labels" RPC, and asking for one would mean a second poll returning the same data.
const ICONS: Record<TransmissionSectionId, LucideIcon> = {
torrents: ListChecks,
stats: Gauge,
settings: Settings,
connection: Plug,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const TransmissionNav = () => {
const { torrents } = useTorrents();
const { stats } = useSessionStats();
const { filters, setStatus, toggle, clearFilters } = useTorrentFilters();
const facets = buildFacets(torrents);
const counts = countByStatus(torrents);
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-warning/15 text-warning ring-1 ring-black/5">
<Waves className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Transmission</div>
<div className="flex items-center gap-2.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1 tabular-nums">
<ArrowDownToLine className="h-3 w-3 text-info" />
{formatSpeed(stats?.downloadSpeed) || '0'}
</span>
<span className="flex items-center gap-1 tabular-nums">
<ArrowUpFromLine className="h-3 w-3 text-success" />
{formatSpeed(stats?.uploadSpeed) || '0'}
</span>
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{TRANSMISSION_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
return (
<NavLink
key={id}
to={transmissionSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
{id === 'torrents' && torrents.length > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">{torrents.length}</span>
)}
</>
)}
</NavLink>
);
})}
</nav>
<div className="flex items-center justify-between px-5 pb-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
{filters.isFiltered && (
<button type="button" onClick={clearFilters} className="text-[10px] font-medium text-primary hover:underline">
clear
</button>
)}
</div>
<div className="flex flex-col gap-0.5 px-2 pb-2">
{STATUS_FILTERS.map(({ id, label }) => (
<FilterRow
key={id}
label={label}
count={counts[id]}
active={filters.status === id}
onClick={() => setStatus(id)}
/>
))}
{facets.errors > 0 && (
<FilterRow
icon={CircleAlert}
label="Tracker errors"
count={facets.errors}
active={filters.errorOnly}
tone="text-warning"
onClick={() => toggle('error', '1', filters.errorOnly ? '1' : null)}
/>
)}
</div>
<FacetGroup
title="Labels"
icon={Tag}
entries={facets.labels}
selected={filters.label}
onPick={(v) => toggle('label', v, filters.label)}
/>
<FacetGroup
title="Trackers"
icon={Radio}
entries={facets.trackers}
selected={filters.tracker}
onPick={(v) => toggle('tracker', v, filters.tracker)}
/>
<FacetGroup
title="Locations"
icon={Folder}
entries={facets.dirs}
selected={filters.downloadDir}
onPick={(v) => toggle('dir', v, filters.downloadDir)}
// A path is only distinguishable by its tail, and the panel is 22% of the window wide.
renderLabel={(v) => v.split('/').filter(Boolean).slice(-1)[0] || v}
/>
<div className="mt-auto px-4 py-3 text-[10px] text-muted-foreground">
{stats ? `${stats.activeTorrentCount} active · ${stats.pausedTorrentCount} paused` : 'connecting…'}
</div>
</div>
);
};
type FilterRowProps = {
label: string;
count: number;
active: boolean;
onClick: () => void;
icon?: LucideIcon;
tone?: string;
title?: string;
};
const FilterRow = ({ label, count, active, onClick, icon: Icon, tone, title }: FilterRowProps) => (
<button
type="button"
onClick={onClick}
title={title ?? label}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
{Icon && <Icon className={`h-3.5 w-3.5 shrink-0 ${tone ?? ''}`} />}
<span className={`min-w-0 flex-1 truncate ${tone ?? ''}`}>{label}</span>
<span className="shrink-0 tabular-nums opacity-60">{count}</span>
</button>
);
type FacetGroupProps = {
title: string;
icon: LucideIcon;
entries: FacetEntry[];
selected: string | null;
onPick: (value: string) => void;
renderLabel?: (value: string) => string;
};
/**
* Collapsible because these lists are open-ended — one tracker per site, one label per whim. Collapsed by
* default unless something in the group is selected, so arriving on a shared filtered URL shows you why.
*/
const FacetGroup = ({ title, icon: Icon, entries, selected, onPick, renderLabel }: FacetGroupProps) => {
const [open, setOpen] = useState(false);
const expanded = open || selected != null;
if (!entries.length) return null;
return (
<div className="px-2 pb-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-muted"
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
<Icon className="h-3 w-3 shrink-0" />
<span className="flex-1">{title}</span>
<span className="tabular-nums opacity-60">{entries.length}</span>
</button>
{expanded && (
<div className="mt-0.5 flex flex-col gap-0.5">
{entries.map(({ value, count }) => (
<FilterRow
key={value}
label={renderLabel ? renderLabel(value) : value}
title={value}
count={count}
active={selected === value}
onClick={() => onPick(value)}
/>
))}
</div>
)}
</div>
);
};