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>
168 lines
6.7 KiB
TypeScript
168 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
};
|