Add a copy button beside node and observer public keys

This commit is contained in:
MrAlders0n
2026-07-12 07:57:52 -04:00
parent c3979326aa
commit dbb4dcac21
4 changed files with 98 additions and 4 deletions
+37
View File
@@ -0,0 +1,37 @@
import { useState, useCallback } from "react";
import { VARIANT_CLASSES } from "./badge-utils";
// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green
// "Copied" state for 1.5s after a click. aria-label defaults to the visible label.
export function CopyButton({
value,
label = "Copy",
copiedLabel = "Copied",
ariaLabel,
className,
}: {
value: string;
label?: string;
copiedLabel?: string;
ariaLabel?: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [value]);
return (
<button
type="button"
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${copied ? VARIANT_CLASSES.live : VARIANT_CLASSES.text} ${className ?? ""}`}
onClick={handleCopy}
aria-label={ariaLabel ?? label}
>
{copied ? copiedLabel : label}
</button>
);
}
+6 -2
View File
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client";
import { Badge } from "../../components/Badge";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { CopyButton } from "../../components/CopyButton";
import { IataChip } from "../../components/IataChip";
import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
@@ -116,8 +117,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</span>
<Badge variant="default">{node.nodeTypeName}</Badge>
</div>
<div className="font-mono text-[13px] text-text-muted truncate" title={node.publicKey}>
{node.publicKey}
<div className="flex items-center gap-2">
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
{node.publicKey}
</div>
<CopyButton value={node.publicKey} ariaLabel="Copy public key" className="shrink-0" />
</div>
{node.observerId && (
<button
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getObserver, getObserverAdverts } from "../../api/client";
import { Badge } from "../../components/Badge";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { CopyButton } from "../../components/CopyButton";
import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { useTick } from "../../hooks/useTick";
@@ -145,8 +146,11 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
{status}
</Badge>
</div>
<div className="font-mono text-[13px] text-text-muted truncate mb-1.5" title={observer.publicKey}>
{observer.publicKey}
<div className="flex items-center gap-2 mb-1.5">
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={observer.publicKey}>
{observer.publicKey}
</div>
<CopyButton value={observer.publicKey} ariaLabel="Copy public key" className="shrink-0" />
</div>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="Observations" value={observer.observationCount.toLocaleString()} />
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { CopyButton } from "../../src/components/CopyButton";
const writeText = vi.fn();
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
writeText.mockClear();
});
describe("CopyButton", () => {
it("shows the default 'Copy' label", () => {
render(<CopyButton value="deadbeef" />);
expect(screen.getByRole("button")).toHaveTextContent("Copy");
});
it("writes the full value to the clipboard on click", () => {
const key = "0123456789abcdef0123456789abcdef";
render(<CopyButton value={key} />);
fireEvent.click(screen.getByRole("button"));
expect(writeText).toHaveBeenCalledWith(key);
});
it("swaps to 'Copied' after clicking, then reverts", () => {
vi.useFakeTimers();
try {
render(<CopyButton value="deadbeef" />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(button).toHaveTextContent("Copied");
act(() => {
vi.advanceTimersByTime(1500);
});
expect(button).toHaveTextContent("Copy");
} finally {
vi.useRealTimers();
}
});
it("uses the provided aria-label for the accessible name", () => {
render(<CopyButton value="deadbeef" ariaLabel="Copy public key" />);
expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument();
});
});