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>
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { Loader2, X } from 'lucide-react';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { statusLabel, statusTone, TONE_CLASSES } from './format';
|
|
import { useTorrentDetail } from './useTransmissionData';
|
|
import { GeneralTab } from './detail/GeneralTab';
|
|
import { FilesTab } from './detail/FilesTab';
|
|
import { PeersTab } from './detail/PeersTab';
|
|
import { TrackersTab } from './detail/TrackersTab';
|
|
import { TrackersDialog } from './dialogs/TrackersDialog';
|
|
|
|
// The bottom pane of the torrents section, driven by ?selected= in the URL.
|
|
//
|
|
// It fetches its own torrent rather than being handed the row from the list: the list poll deliberately
|
|
// omits files, peers and peersFrom, and threading two shapes of the same object through the tree is how
|
|
// you end up rendering a stale peer list.
|
|
|
|
type TorrentDetailProps = { id: number; onClose: () => void };
|
|
|
|
export const TorrentDetail = ({ id, onClose }: TorrentDetailProps) => {
|
|
const { torrent, isLoading } = useTorrentDetail(id);
|
|
const [editingTrackers, setEditingTrackers] = useState(false);
|
|
|
|
if (isLoading && !torrent) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Loading…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!torrent) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">That torrent is gone.</div>
|
|
);
|
|
}
|
|
|
|
const tone = TONE_CLASSES[statusTone(torrent)];
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col border-t border-border">
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${tone.dot}`} />
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={torrent.name}>
|
|
{torrent.name}
|
|
</span>
|
|
<span className={`shrink-0 text-xs ${tone.text}`}>{statusLabel(torrent)}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
aria-label="Close details"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<Tabs defaultValue="general" className="flex min-h-0 flex-1 flex-col">
|
|
<TabsList className="mx-3 mt-2 w-fit shrink-0">
|
|
<TabsTrigger value="general">General</TabsTrigger>
|
|
<TabsTrigger value="files">Files{torrent.files ? ` (${torrent.files.length})` : ''}</TabsTrigger>
|
|
<TabsTrigger value="peers">Peers{torrent.peers ? ` (${torrent.peers.length})` : ''}</TabsTrigger>
|
|
<TabsTrigger value="trackers">
|
|
Trackers{torrent.trackerStats ? ` (${torrent.trackerStats.length})` : ''}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* mt-0 undoes the Tabs default spacing; these panes own their own scrolling. */}
|
|
<TabsContent value="general" className="mt-0 min-h-0 flex-1 overflow-auto">
|
|
<GeneralTab torrent={torrent} />
|
|
</TabsContent>
|
|
<TabsContent value="files" className="mt-0 min-h-0 flex-1">
|
|
<FilesTab torrent={torrent} />
|
|
</TabsContent>
|
|
<TabsContent value="peers" className="mt-0 min-h-0 flex-1">
|
|
<PeersTab torrent={torrent} />
|
|
</TabsContent>
|
|
<TabsContent value="trackers" className="mt-0 min-h-0 flex-1">
|
|
<TrackersTab torrent={torrent} onEdit={() => setEditingTrackers(true)} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<TrackersDialog open={editingTrackers} onOpenChange={setEditingTrackers} torrent={torrent} />
|
|
</div>
|
|
);
|
|
};
|