20260623 optimize rpki explorer ui interactions

This commit is contained in:
2026-06-23 15:47:50 +08:00
parent 4f41fbe04e
commit 574e40a4d4
16 changed files with 1483 additions and 297 deletions

View File

@@ -0,0 +1,88 @@
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
interface CopyableValueProps {
className?: string;
displayValue?: string;
label: string;
value: string;
}
interface TooltipPosition {
left: number;
top: number;
}
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
useEffect(() => {
if (copyState === "idle") {
return;
}
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
return () => window.clearTimeout(timeout);
}, [copyState]);
const showTooltip = (target: HTMLElement) => {
const rect = target.getBoundingClientRect();
setTooltipPosition({
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
});
};
const copy = async () => {
try {
await copyText(value);
setCopyState("copied");
} catch {
setCopyState("failed");
}
};
return (
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
<span
className="copyable-value-text"
onBlur={() => setTooltipPosition(null)}
onFocus={(event) => showTooltip(event.currentTarget)}
onMouseEnter={(event) => showTooltip(event.currentTarget)}
onMouseLeave={() => setTooltipPosition(null)}
tabIndex={0}
title={value}
>
{displayValue ?? value}
</span>
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
</button>
{tooltipPosition ? (
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
{value}
</span>
) : null}
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
</span>
);
}
async function copyText(value: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(value);
return;
}
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(textArea);
}
}

View File

@@ -0,0 +1,32 @@
interface CursorPagerProps {
label?: string;
isFetching: boolean;
nextCursor: string | null;
onNext: () => void;
onPrevious: () => void;
pageNumber: number;
previousCount: number;
rangeLabel?: string;
}
export function CursorPager({
label,
isFetching,
nextCursor,
onNext,
onPrevious,
pageNumber,
previousCount,
rangeLabel
}: CursorPagerProps) {
return (
<div className="cursor-pager" aria-label="Cursor pagination">
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
<span>
{label ?? `Page ${pageNumber}`}
{rangeLabel ? <small>{rangeLabel}</small> : null}
</span>
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export interface PageState {
cursor: string | null;
previous: Array<string | null>;
}
export function advancePage(page: PageState, nextCursor: string | null): PageState {
if (!nextCursor) {
return page;
}
return {
cursor: nextCursor,
previous: [...page.previous, page.cursor]
};
}
export function previousPage(page: PageState): PageState {
if (page.previous.length === 0) {
return page;
}
return {
cursor: page.previous[page.previous.length - 1] ?? null,
previous: page.previous.slice(0, -1)
};
}

View File

@@ -1,26 +1,29 @@
import type { LucideIcon } from "lucide-react";
import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import type { PropsWithChildren } from "react";
import { useState } from "react";
export interface NavigationItem {
id: string;
export interface NavigationItem<Id extends string = string> {
id: Id;
label: string;
icon: LucideIcon;
}
interface ShellProps extends PropsWithChildren {
activeView: string;
navigationItems: NavigationItem[];
onNavigate: (id: string) => void;
interface ShellProps<Id extends string> extends PropsWithChildren {
activeView: Id;
navigationItems: Array<NavigationItem<Id>>;
onNavigate: (id: Id) => void;
}
export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) {
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return (
<div className="app-shell">
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
<aside className="sidebar" aria-label="Primary navigation">
<div className="brand">
<div className="brand-mark">R</div>
<div>
<div className="brand-copy">
<h1 className="brand-title">RPKI Explorer</h1>
<div className="brand-subtitle">Validation Intelligence</div>
</div>
@@ -29,9 +32,11 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
{navigationItems.map((item) => (
<button
aria-current={activeView === item.id ? "page" : undefined}
aria-label={isSidebarCollapsed ? item.label : undefined}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
key={item.id}
onClick={() => onNavigate(item.id)}
title={isSidebarCollapsed ? item.label : undefined}
type="button"
>
<item.icon aria-hidden="true" size={18} />
@@ -39,24 +44,31 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
</button>
))}
</nav>
<button
aria-expanded={!isSidebarCollapsed}
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
className="sidebar-toggle"
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
type="button"
>
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
</button>
</aside>
<main className="main-panel">
<header className="topbar">
<div className="run-selector" aria-label="Current run">
<div className="run-selector readonly-run" aria-label="Current run">
<span>Run</span>
<strong>latest run</strong>
<ChevronDown aria-hidden="true" size={16} />
<strong>latest indexed run</strong>
</div>
<div className="topbar-actions">
<label className="search-box">
<Search aria-hidden="true" size={18} />
<span className="sr-only">Search URI or hash</span>
<input placeholder="Search URI / hash / ASN / prefix…" />
<span className="sr-only">Exact object URI search is not active yet</span>
<input disabled placeholder="Exact URI lookup planned in M2" />
</label>
<div className="ready-pill"><span /> Ready</div>
<button aria-label="Help" className="icon-button" type="button"><CircleHelp size={19} /></button>
<button aria-label="Theme" className="icon-button" type="button"><Sun size={19} /></button>
<button aria-label="Analytics" className="icon-button" type="button"><BarChart3 size={19} /></button>
<div className="ready-pill"><span /> UI Ready</div>
</div>
</header>
{children}