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,245 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight, File, Folder, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { PRIORITY } from '../shared';
|
||||
import { formatPercent, formatSize, PRIORITY_LABELS } from '../format';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
import { RenameDialog } from '../dialogs/RenameDialog';
|
||||
|
||||
// Files, as a tree, because a torrent of a TV season is thirty files in three folders and a flat list of
|
||||
// thirty identical-looking paths is unusable.
|
||||
//
|
||||
// Transmission addresses files by their INDEX in the `files` array, not by path — so every node carries the
|
||||
// indices beneath it, and a folder operation is just the same RPC with more indices.
|
||||
|
||||
type FileNode = {
|
||||
key: string;
|
||||
name: string;
|
||||
/** Leaf files only; a directory's own index list is the union of its descendants'. */
|
||||
index?: number;
|
||||
children: FileNode[];
|
||||
indices: number[];
|
||||
size: number;
|
||||
completed: number;
|
||||
};
|
||||
|
||||
function buildTree(torrent: Torrent): FileNode {
|
||||
const root: FileNode = { key: '', name: '', children: [], indices: [], size: 0, completed: 0 };
|
||||
const files = torrent.files ?? [];
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const segments = file.name.split('/').filter(Boolean);
|
||||
let node = root;
|
||||
segments.forEach((segment, depth) => {
|
||||
const isLeaf = depth === segments.length - 1;
|
||||
const key = segments.slice(0, depth + 1).join('/');
|
||||
let child = node.children.find((c) => c.key === key);
|
||||
if (!child) {
|
||||
child = { key, name: segment, children: [], indices: [], size: 0, completed: 0 };
|
||||
node.children.push(child);
|
||||
}
|
||||
if (isLeaf) child.index = index;
|
||||
node = child;
|
||||
});
|
||||
});
|
||||
|
||||
// Roll sizes and indices up in one post-order pass, so a folder row can show its own progress bar.
|
||||
const roll = (node: FileNode): FileNode => {
|
||||
if (node.index != null) {
|
||||
const file = files[node.index]!;
|
||||
node.size = file.length;
|
||||
node.completed = file.bytesCompleted;
|
||||
node.indices = [node.index];
|
||||
return node;
|
||||
}
|
||||
node.children.forEach(roll);
|
||||
node.size = node.children.reduce((s, c) => s + c.size, 0);
|
||||
node.completed = node.children.reduce((s, c) => s + c.completed, 0);
|
||||
node.indices = node.children.flatMap((c) => c.indices);
|
||||
// Folders before files, then alphabetical — the ordering every file manager uses.
|
||||
node.children.sort((a, b) => Number(!!a.index) - Number(!!b.index) || a.name.localeCompare(b.name));
|
||||
return node;
|
||||
};
|
||||
|
||||
return roll(root);
|
||||
}
|
||||
|
||||
type FilesTabProps = { torrent: Torrent };
|
||||
|
||||
export const FilesTab = ({ torrent }: FilesTabProps) => {
|
||||
const { set } = useTorrentMutations();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
const tree = useMemo(() => buildTree(torrent), [torrent.files, torrent.id]);
|
||||
const stats = torrent.fileStats ?? [];
|
||||
|
||||
if (!torrent.files?.length) {
|
||||
return (
|
||||
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||
{torrent.metadataPercentComplete < 1
|
||||
? 'Still fetching metadata — the file list appears once the magnet resolves.'
|
||||
: 'This torrent has no files.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const wantedCount = stats.filter((s) => s.wanted).length;
|
||||
const wantedBytes = torrent.files.reduce((sum, f, i) => sum + (stats[i]?.wanted === false ? 0 : f.length), 0);
|
||||
|
||||
const setWanted = (indices: number[], wanted: boolean) =>
|
||||
set.mutate({ ids: [torrent.id], [wanted ? 'files-wanted' : 'files-unwanted']: indices });
|
||||
|
||||
const setPriority = (indices: number[], priority: number) => {
|
||||
const field =
|
||||
priority === PRIORITY.High ? 'priority-high' : priority === PRIORITY.Low ? 'priority-low' : 'priority-normal';
|
||||
set.mutate({ ids: [torrent.id], [field]: indices });
|
||||
};
|
||||
|
||||
const toggleCollapsed = (key: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const renderNode = (node: FileNode, depth: number): React.ReactNode[] => {
|
||||
const isDir = node.index == null;
|
||||
const isCollapsed = collapsed.has(node.key);
|
||||
const nodeStats = node.indices.map((i) => stats[i]).filter(Boolean);
|
||||
const allWanted = nodeStats.length > 0 && nodeStats.every((s) => s!.wanted);
|
||||
const someWanted = nodeStats.some((s) => s!.wanted);
|
||||
const priorities = new Set(nodeStats.map((s) => s!.priority));
|
||||
const priority = priorities.size === 1 ? [...priorities][0]! : null;
|
||||
const fraction = node.size > 0 ? node.completed / node.size : 0;
|
||||
|
||||
const row = (
|
||||
<div
|
||||
key={node.key}
|
||||
className="grid grid-cols-[minmax(0,1fr)_90px_120px_90px_28px] items-center gap-2 border-b border-border/40 px-3 py-1.5 text-xs hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}>
|
||||
{isDir ? (
|
||||
<button type="button" onClick={() => toggleCollapsed(node.key)} className="shrink-0">
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={allWanted ? true : someWanted ? 'indeterminate' : false}
|
||||
onCheckedChange={() => setWanted(node.indices, !allWanted)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
{isDir ? (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className={`truncate ${someWanted ? '' : 'text-muted-foreground line-through'}`} title={node.key}>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-right tabular-nums text-muted-foreground">{formatSize(node.size)}</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-info" style={{ width: `${fraction * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="text-left text-muted-foreground hover:text-foreground">
|
||||
{priority == null ? 'mixed' : PRIORITY_LABELS[priority]}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.High)}>High</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Normal)}>Normal</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Low)}>Low</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(node.key)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Rename ${node.name}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDir && !isCollapsed) {
|
||||
return [row, ...node.children.flatMap((child) => renderNode(child, depth + 1))];
|
||||
}
|
||||
return [row];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{wantedCount} of {torrent.files.length} files · {formatSize(wantedBytes)} wanted
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setWanted(
|
||||
torrent.files!.map((_, i) => i),
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
Set all priorities
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[PRIORITY.High, PRIORITY.Normal, PRIORITY.Low].map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p}
|
||||
onSelect={() =>
|
||||
setPriority(
|
||||
torrent.files!.map((_, i) => i),
|
||||
p,
|
||||
)
|
||||
}
|
||||
>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">{tree.children.flatMap((child) => renderNode(child, 0))}</div>
|
||||
|
||||
<RenameDialog
|
||||
open={renaming != null}
|
||||
onOpenChange={(open) => !open && setRenaming(null)}
|
||||
torrent={torrent}
|
||||
path={renaming ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Torrent } from '../shared';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatEta,
|
||||
formatPercent,
|
||||
formatRatio,
|
||||
formatSize,
|
||||
formatSpeed,
|
||||
PRIORITY_LABELS,
|
||||
statusLabel,
|
||||
} from '../format';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// The "what is this torrent" tab: transfer numbers, then the immutable facts from the metainfo.
|
||||
|
||||
type GeneralTabProps = { torrent: Torrent };
|
||||
|
||||
export const GeneralTab = ({ torrent: t }: GeneralTabProps) => (
|
||||
<div className="space-y-5 p-4">
|
||||
{t.error !== 0 && t.errorString && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">{t.errorString}</p>
|
||||
)}
|
||||
|
||||
<Section title="Transfer">
|
||||
<Field label="Status" value={statusLabel(t)} />
|
||||
<Field label="Progress" value={formatPercent(t.percentDone)} />
|
||||
<Field label="Downloaded" value={formatSize(t.downloadedEver)} />
|
||||
<Field label="Uploaded" value={formatSize(t.uploadedEver)} />
|
||||
<Field label="Ratio" value={formatRatio(t.uploadRatio)} />
|
||||
<Field label="Remaining" value={formatSize(t.leftUntilDone)} />
|
||||
<Field label="Down speed" value={formatSpeed(t.rateDownload) || '—'} />
|
||||
<Field label="Up speed" value={formatSpeed(t.rateUpload) || '—'} />
|
||||
<Field label="ETA" value={t.percentDone >= 1 ? '—' : formatEta(t.eta)} />
|
||||
<Field label="Seeds / peers" value={`${t.peersSendingToUs} / ${t.peersGettingFromUs}`} />
|
||||
<Field label="Seeding time" value={formatDuration(t.secondsSeeding)} />
|
||||
{/* Corrupt bytes are re-downloaded silently, so this is the only place a failing disk or a bad peer
|
||||
shows up before the ratio quietly goes wrong. */}
|
||||
<Field label="Corrupt" value={t.corruptEver ? formatSize(t.corruptEver) : '—'} />
|
||||
</Section>
|
||||
|
||||
<Section title="Dates">
|
||||
<Field label="Added" value={formatDate(t.addedDate)} />
|
||||
<Field label="Completed" value={formatDate(t.doneDate)} />
|
||||
<Field label="Last active" value={formatDate(t.activityDate)} />
|
||||
<Field label="Created" value={formatDate(t.dateCreated)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Torrent">
|
||||
<Field label="Total size" value={formatSize(t.totalSize)} />
|
||||
<Field label="Files" value={String(t['file-count'] ?? '—')} />
|
||||
<Field label="Pieces" value={`${t.pieceCount} × ${formatSize(t.pieceSize)}`} />
|
||||
<Field label="Privacy" value={t.isPrivate ? 'Private torrent' : 'Public torrent'} />
|
||||
<Field label="Priority" value={PRIORITY_LABELS[t.bandwidthPriority] ?? '—'} />
|
||||
<Field label="Queue position" value={String(t.queuePosition)} />
|
||||
<Field label="Creator" value={t.creator || '—'} />
|
||||
<Field label="Peer limit" value={String(t['peer-limit'] ?? '—')} />
|
||||
</Section>
|
||||
|
||||
<Section title="Location" columns={1}>
|
||||
<Field label="Download directory" value={t.downloadDir} mono />
|
||||
<Field label="Hash" value={t.hashString ?? '—'} mono copy={t.hashString} />
|
||||
<Field label="Magnet" value={t.magnetLink || '—'} mono truncate copy={t.magnetLink} />
|
||||
{t.labels.length > 0 && <Field label="Labels" value={t.labels.join(', ')} />}
|
||||
{t.comment && <Field label="Comment" value={t.comment} />}
|
||||
</Section>
|
||||
|
||||
{t.peersFrom && (
|
||||
<Section title="Peer sources">
|
||||
<Field label="Tracker" value={String(t.peersFrom.fromTracker)} />
|
||||
<Field label="DHT" value={String(t.peersFrom.fromDht)} />
|
||||
<Field label="PEX" value={String(t.peersFrom.fromPex)} />
|
||||
<Field label="Incoming" value={String(t.peersFrom.fromIncoming)} />
|
||||
<Field label="Local discovery" value={String(t.peersFrom.fromLpd)} />
|
||||
<Field label="Cache" value={String(t.peersFrom.fromCache)} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = ({ title, children, columns = 2 }: { title: string; children: ReactNode; columns?: number }) => (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
<dl className={`grid gap-x-6 gap-y-1.5 ${columns === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||
{children}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
|
||||
type FieldProps = { label: string; value: string; mono?: boolean; truncate?: boolean; copy?: string };
|
||||
|
||||
const Field = ({ label, value, mono, truncate, copy }: FieldProps) => (
|
||||
<div className="flex min-w-0 items-baseline gap-3 text-xs">
|
||||
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className={`min-w-0 flex-1 ${mono ? 'font-mono' : ''} ${truncate ? 'truncate' : 'break-words'}`} title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
{copy && <CopyButton value={copy} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CopyButton = ({ value }: { value: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
// copyToClipboard handles the insecure-origin case — navigator.clipboard does not exist over
|
||||
// plain http, which Officer IS reached over on a tailnet. This comment used to say "Officer is
|
||||
// always behind HTTPS"; it was not, and the button silently copied nothing.
|
||||
void copyToClipboard(value).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}}
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { describePeerFlags, formatPercent, formatSpeed } from '../format';
|
||||
|
||||
// Connected peers. Not sortable and not virtualised on purpose: the list is capped by the torrent's peer
|
||||
// limit (50 by default), it churns every poll, and a sort you have to re-establish every five seconds is
|
||||
// worse than no sort.
|
||||
|
||||
type PeersTabProps = { torrent: Torrent };
|
||||
|
||||
const HEAD = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
|
||||
export const PeersTab = ({ torrent }: PeersTabProps) => {
|
||||
const peers = torrent.peers ?? [];
|
||||
|
||||
if (!peers.length) {
|
||||
return <p className="p-6 text-center text-sm text-muted-foreground">No peers connected.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-background/95 backdrop-blur">
|
||||
<tr className="border-b border-border">
|
||||
<th className={HEAD}>Address</th>
|
||||
<th className={HEAD}>Client</th>
|
||||
<th className={`${HEAD} text-right`}>Progress</th>
|
||||
<th className={`${HEAD} text-right`}>Down</th>
|
||||
<th className={`${HEAD} text-right`}>Up</th>
|
||||
<th className={HEAD}>Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((peer) => (
|
||||
<tr key={`${peer.address}:${peer.port}`} className="border-b border-border/40 hover:bg-muted/40">
|
||||
<td className="px-3 py-1.5 font-mono">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{peer.isEncrypted && <Lock className="h-3 w-3 shrink-0 text-success" aria-label="Encrypted" />}
|
||||
{peer.address}:{peer.port}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-3 py-1.5" title={peer.clientName}>
|
||||
{peer.clientName || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">{formatPercent(peer.progress)}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-info">{formatSpeed(peer.rateToClient)}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-success">{formatSpeed(peer.rateToPeer)}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground" title={describePeerFlags(peer.flagStr)}>
|
||||
{peer.flagStr}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { CircleAlert, CircleCheck, Pencil, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDate } from '../format';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// One card per tracker, grouped by tier. The point of this tab is diagnosing a torrent that isn't finding
|
||||
// peers, so the failure text gets as much room as the success numbers.
|
||||
|
||||
type TrackersTabProps = { torrent: Torrent; onEdit: () => void };
|
||||
|
||||
export const TrackersTab = ({ torrent, onEdit }: TrackersTabProps) => {
|
||||
const { act } = useTorrentMutations();
|
||||
const trackers = torrent.trackerStats ?? [];
|
||||
|
||||
const tiers = new Map<number, typeof trackers>();
|
||||
for (const tracker of trackers) {
|
||||
tiers.set(tracker.tier, [...(tiers.get(tracker.tier) ?? []), tracker]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{trackers.length} tracker{trackers.length === 1 ? '' : 's'} in {tiers.size} tier
|
||||
{tiers.size === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => act.mutate({ ids: [torrent.id], action: 'reannounce' })}>
|
||||
<RefreshCw className="mr-1.5 h-3 w-3" />
|
||||
Reannounce
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onEdit}>
|
||||
<Pencil className="mr-1.5 h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{trackers.length === 0 && (
|
||||
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||
No trackers. This torrent relies on DHT and peer exchange.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 p-3">
|
||||
{[...tiers.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([tier, list]) => (
|
||||
<section key={tier}>
|
||||
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Tier {tier + 1}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{list.map((tracker) => (
|
||||
<div key={tracker.id} className="rounded-lg border border-border p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
{tracker.lastAnnounceSucceeded ? (
|
||||
<CircleCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" />
|
||||
) : (
|
||||
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="break-all font-mono">{tracker.announce}</div>
|
||||
{tracker.isBackup && (
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
Backup — only tried if the tier above fails entirely.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right tabular-nums text-muted-foreground">
|
||||
{tracker.seederCount >= 0 ? `${tracker.seederCount} seeds` : 'seeds unknown'}
|
||||
<br />
|
||||
{tracker.leecherCount >= 0 ? `${tracker.leecherCount} peers` : 'peers unknown'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||
<Row label="Last announce" value={formatDate(tracker.lastAnnounceTime)} />
|
||||
<Row
|
||||
label="Result"
|
||||
value={tracker.lastAnnounceResult || '—'}
|
||||
tone={tracker.lastAnnounceSucceeded ? undefined : 'text-warning'}
|
||||
/>
|
||||
<Row label="Next announce" value={formatDate(tracker.nextAnnounceTime)} />
|
||||
<Row label="Peers returned" value={String(tracker.lastAnnouncePeerCount)} />
|
||||
<Row label="Last scrape" value={formatDate(tracker.lastScrapeTime)} />
|
||||
<Row
|
||||
label="Scrape result"
|
||||
value={tracker.lastScrapeResult || '—'}
|
||||
tone={tracker.lastScrapeSucceeded ? undefined : 'text-warning'}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className={`min-w-0 flex-1 break-words ${tone ?? ''}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user