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>
129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
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>
|
||
);
|
||
};
|