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>
240 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|