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,167 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { Torrent } from './shared';
|
||||
import type { ColumnDef, ColumnKey } from './columns';
|
||||
import type { SortKey } from './useTorrentFilters';
|
||||
import { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowDown, ArrowUp, Check } from 'lucide-react';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { ALL_COLUMNS, PINNED_COLUMN } from './columns';
|
||||
|
||||
// The torrent list. Virtualised because the row count is unbounded and the whole thing re-renders on a
|
||||
// 5-second poll — 43 rows is fine, 4000 is not, and a torrent client accumulates.
|
||||
//
|
||||
// Layout is a fixed-width grid inside a single horizontal scroller shared by the header and the body, so
|
||||
// the two cannot drift apart the way two independently-scrolled elements do.
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
|
||||
type TorrentTableProps = {
|
||||
torrents: Torrent[];
|
||||
columns: ColumnDef[];
|
||||
/** Multi-selection, for the bulk actions in the toolbar and the row context menu. */
|
||||
selection: Set<number>;
|
||||
/** The one torrent whose detail pane is open (?selected=) — a different thing from being in `selection`. */
|
||||
focused: number | null;
|
||||
onRowClick: (torrent: Torrent, ev: MouseEvent) => void;
|
||||
sort: SortKey;
|
||||
desc: boolean;
|
||||
onSort: (key: SortKey) => void;
|
||||
visibleKeys: ColumnKey[];
|
||||
onToggleColumn: (key: ColumnKey) => void;
|
||||
onResetColumns: () => void;
|
||||
/** Right-clicking a row selects it if it isn't already, then opens the actions menu. */
|
||||
onRowContextMenu: (torrent: Torrent) => void;
|
||||
rowMenu: (torrent: Torrent) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const TorrentTable = ({
|
||||
torrents,
|
||||
columns,
|
||||
selection,
|
||||
focused,
|
||||
onRowClick,
|
||||
sort,
|
||||
desc,
|
||||
onSort,
|
||||
visibleKeys,
|
||||
onToggleColumn,
|
||||
onResetColumns,
|
||||
onRowContextMenu,
|
||||
rowMenu,
|
||||
}: TorrentTableProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: torrents.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
const gridTemplate = columns.map((c) => `${c.width}px`).join(' ');
|
||||
const totalWidth = columns.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-auto">
|
||||
{/* min-w on an inline-block wrapper: the header must be as wide as the widest row so it keeps
|
||||
scrolling with the body rather than clipping at the viewport edge. */}
|
||||
<div style={{ minWidth: totalWidth }}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<button
|
||||
key={col.key}
|
||||
type="button"
|
||||
disabled={!col.sortKey}
|
||||
onClick={() => col.sortKey && onSort(col.sortKey)}
|
||||
className={`flex items-center gap-1 overflow-hidden px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
|
||||
col.sortKey ? 'hover:text-foreground' : 'cursor-default'
|
||||
} ${col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : ''}`}
|
||||
>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{col.sortKey === sort &&
|
||||
(desc ? <ArrowDown className="h-3 w-3 shrink-0" /> : <ArrowUp className="h-3 w-3 shrink-0" />)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="max-h-[70vh] w-56 overflow-y-auto">
|
||||
{ALL_COLUMNS.map((col) => {
|
||||
const shown = visibleKeys.includes(col.key);
|
||||
const pinned = col.key === PINNED_COLUMN;
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={col.key}
|
||||
disabled={pinned}
|
||||
onSelect={(ev) => {
|
||||
// Keep the menu open — picking columns is a several-clicks job and reopening it each
|
||||
// time, at the same mouse position, is a small torture.
|
||||
ev.preventDefault();
|
||||
onToggleColumn(col.key);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-3.5 w-3.5 ${shown ? '' : 'opacity-0'}`} />
|
||||
{col.label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})}
|
||||
<ContextMenuItem onSelect={onResetColumns}>Reset to defaults</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const torrent = torrents[row.index];
|
||||
if (!torrent) return null;
|
||||
const isSelected = selection.has(torrent.id);
|
||||
const isFocused = focused === torrent.id;
|
||||
|
||||
return (
|
||||
<ContextMenu key={torrent.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
onClick={(ev) => onRowClick(torrent, ev)}
|
||||
onContextMenu={() => onRowContextMenu(torrent)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: row.size,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
gridTemplateColumns: gridTemplate,
|
||||
}}
|
||||
className={`grid cursor-default items-center border-b border-border/40 text-xs ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
} ${isFocused ? 'ring-1 ring-inset ring-primary/40' : ''}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`min-w-0 overflow-hidden whitespace-nowrap px-2 ${
|
||||
col.align === 'right'
|
||||
? 'text-right tabular-nums'
|
||||
: col.align === 'center'
|
||||
? 'text-center'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{col.render(torrent)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">{rowMenu(torrent)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user