Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compa
if (status !== "connecting") return;
function update() {
const staleSec = Math.floor((Date.now() - wsManager.getLastEventTimestamp()) / 1000);
setStaleStr(staleSec > 60 ? `${Math.floor(staleSec / 60)}m` : `${staleSec}s`);
setStaleStr(staleSec > 60 ? t("timestamp.unit.m", { count: Math.floor(staleSec / 60) }) : t("timestamp.unit.s", { count: staleSec }));
}
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [status, wsManager]);
}, [status, wsManager, t]);

if (status === "connected") {
if (compact) return (
Expand Down Expand Up @@ -364,12 +364,13 @@ function ThemeOptions({ onPick }: { onPick?: () => void }) {
}

function GitHubLink() {
const { t } = useTranslation();
return (
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
aria-label={t("header.github")}
className="text-text-muted hover:text-text-normal transition-colors shrink-0"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
Expand Down
7 changes: 5 additions & 2 deletions src/components/CloseButton.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { useTranslation } from "react-i18next";

// Shared dismiss control: a bold X with a hover background. Larger tap target on mobile, compact at md+.
export function CloseButton({ onClose, label = "Close", className }: {
export function CloseButton({ onClose, label, className }: {
onClose: () => void;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClose}
aria-label={label}
aria-label={label ?? t("common.close")}
className={`flex items-center justify-center w-9 h-9 md:w-7 md:h-7 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors ${className ?? ""}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
Expand Down
8 changes: 6 additions & 2 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
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",
label,
copiedLabel,
ariaLabel,
className,
}: {
Expand All @@ -16,7 +17,10 @@ export function CopyButton({
ariaLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
label ??= t("common.copy");
copiedLabel ??= t("common.copied");

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value);
Expand Down
11 changes: 7 additions & 4 deletions src/components/CopyLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { VARIANT_CLASSES } from "./badge-utils";

// Copies a shareable deep link to the current page with the given query params set (built fresh from
Expand All @@ -7,8 +8,8 @@ import { VARIANT_CLASSES } from "./badge-utils";
// (e.g. the map camera) and use a null value to delete a key that's now at its default.
export function CopyLinkButton({
params,
label = "Copy link",
copiedLabel = "Copied",
label,
copiedLabel,
ariaLabel,
preserveParams,
}: {
Expand All @@ -18,7 +19,9 @@ export function CopyLinkButton({
ariaLabel?: string;
preserveParams?: readonly string[];
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const idleLabel = label ?? t("common.copyLink");

const handleCopy = useCallback(() => {
const url = new URL(window.location.href);
Expand All @@ -38,9 +41,9 @@ export function CopyLinkButton({
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}`}
onClick={handleCopy}
aria-label={ariaLabel ?? label}
aria-label={ariaLabel ?? idleLabel}
>
{copied ? copiedLabel : label}
{copied ? copiedLabel ?? t("common.copied") : idleLabel}
</button>
);
}
13 changes: 8 additions & 5 deletions src/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CloseButton } from "./CloseButton";

// shared scaffolding for the right-hand entity detail panels (observers, nodes, …)
Expand All @@ -20,11 +21,12 @@ export function Field({ label, value }: { label: string; value: ReactNode }) {

// Minimize/expand toggle for the mobile overlay only; at md+ the panel is a sidebar so it's hidden.
export function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand detail panel" : "Minimize detail panel"}
aria-label={t(collapsed ? "common.expandDetailPanel" : "common.minimizeDetailPanel")}
className="md:hidden flex items-center justify-center w-9 h-9 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
Expand All @@ -50,7 +52,8 @@ interface DetailPanelProps {
children: ReactNode;
}

export function DetailPanel({ title, onClose, closeLabel = "Close detail panel", collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) {
export function DetailPanel({ title, onClose, closeLabel, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel, headerAction, children }: DetailPanelProps) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
// collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:*
// classes below always win at desktop width, so a lingering collapsed state never hides the sidebar.
Expand All @@ -63,20 +66,20 @@ export function DetailPanel({ title, onClose, closeLabel = "Close detail panel",
{headerAction}
<div className="flex items-center gap-0.5">
{collapsible && <MinimizeButton collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />}
<CloseButton onClose={onClose} label={closeLabel} />
<CloseButton onClose={onClose} label={closeLabel ?? t("common.closeDetailPanel")} />
</div>
</div>
</div>

<div className={`flex-1 overflow-y-auto min-h-0 ${minimized ? "hidden md:block" : ""}`}>
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
<span className="text-[13px] font-mono">Loading...</span>
<span className="text-[13px] font-mono">{t("common.loadingDots")}</span>
</div>
) : notFound ? (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
{notFoundIcon}
<span className="text-[13px] font-mono">{notFoundLabel}</span>
<span className="text-[13px] font-mono">{notFoundLabel ?? t("common.notFound")}</span>
</div>
) : (
children
Expand Down
30 changes: 18 additions & 12 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, type ReactNode } from "react";
import { useTranslation } from "react-i18next";

interface Props {
children: ReactNode;
Expand All @@ -22,19 +23,24 @@ export class ErrorBoundary extends Component<Props, State> {
// React.lazy caches a rejected import, so remounting can't recover a failed chunk load
// (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can
const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message);
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
<p className="text-sm font-mono">Something went wrong rendering this view.</p>
<button
type="button"
className="text-xs font-mono underline cursor-pointer text-primary"
onClick={() => (chunkFailure ? window.location.reload() : this.setState({ error: null }))}
>
{chunkFailure ? "reload" : "try again"}
</button>
</div>
);
return <ErrorFallback chunkFailure={chunkFailure} onRetry={() => this.setState({ error: null })} />;
}
return this.props.children;
}
}

function ErrorFallback({ chunkFailure, onRetry }: { chunkFailure: boolean; onRetry: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
<p className="text-sm font-mono">{t("errorBoundary.message")}</p>
<button
type="button"
className="text-xs font-mono underline cursor-pointer text-primary"
onClick={() => (chunkFailure ? window.location.reload() : onRetry())}
>
{chunkFailure ? t("errorBoundary.reload") : t("errorBoundary.retry")}
</button>
</div>
);
}
31 changes: 17 additions & 14 deletions src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MultiSelectDropdown } from "./MultiSelectDropdown";
import { SearchBar, type SearchFieldOption } from "./SearchBar";
import { FilterSheet, FiltersButton } from "./FilterSheet";
Expand All @@ -10,11 +11,11 @@ interface FilterOption {
label: string;
}

const PACKET_SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "hash", label: "Hash" },
{ value: "path", label: "Latest path" },
{ value: "payload", label: "Payload", disabled: true },
];
const PACKET_SEARCH_FIELDS = [
{ value: "hash", labelKey: "filters.field.hash" },
{ value: "path", labelKey: "filters.field.path" },
{ value: "payload", labelKey: "filters.field.payload", disabled: true },
] as const;

interface FilterBarProps {
typeOptions: FilterOption[];
Expand Down Expand Up @@ -57,7 +58,9 @@ export function FilterBar({
onSearchFieldChange,
onClear,
}: FilterBarProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const searchFields: SearchFieldOption[] = PACKET_SEARCH_FIELDS.map(({ labelKey, ...f }) => ({ ...f, label: t(labelKey) }));
const [sheetOpen, setSheetOpen] = useState(false);
// close the sheet when leaving mobile — derive during render, not in an effect
if (sheetOpen && !isMobile) setSheetOpen(false);
Expand All @@ -75,19 +78,19 @@ export function FilterBar({
// shared by the desktop inline bar and the mobile filter sheet
const controls = (fullWidth: boolean) => (
<>
<MultiSelectDropdown label="Types" options={typeOptions} selected={activeTypes} onChange={onTypesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label="Routes" options={routeOptions} selected={activeRoutes} onChange={onRoutesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label="Observers" options={observerOptions} selected={activeObservers} onChange={onObserversChange} searchable align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.types")} options={typeOptions} selected={activeTypes} onChange={onTypesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.routes")} options={routeOptions} selected={activeRoutes} onChange={onRoutesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.observers")} options={observerOptions} selected={activeObservers} onChange={onObserversChange} searchable align="right" fullWidth={fullWidth} />
{scopeOptions.length > 0 && (
<MultiSelectDropdown label="Scope" options={scopeOptions} selected={activeScopes} onChange={onScopesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.scope")} options={scopeOptions} selected={activeScopes} onChange={onScopesChange} align="right" fullWidth={fullWidth} />
)}
</>
);

if (isMobile) {
return (
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base" role="toolbar" aria-label="Packet filters">
<SearchBar value={search} onChange={onSearchChange} fields={PACKET_SEARCH_FIELDS} field={searchField} onFieldChange={(f) => onSearchFieldChange(f as SearchField)} />
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base" role="toolbar" aria-label={t("filters.packetFilters")}>
<SearchBar value={search} onChange={onSearchChange} fields={searchFields} field={searchField} onFieldChange={(f) => onSearchFieldChange(f as SearchField)} />
<FiltersButton activeCount={activeCount} onClick={() => setSheetOpen(true)} />
{sheetOpen && (
<FilterSheet onClose={() => setSheetOpen(false)} onClear={hasFilters ? onClear : undefined}>
Expand All @@ -102,12 +105,12 @@ export function FilterBar({
<div
className="flex flex-wrap items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base"
role="toolbar"
aria-label="Packet filters"
aria-label={t("filters.packetFilters")}
>
<SearchBar
value={search}
onChange={onSearchChange}
fields={PACKET_SEARCH_FIELDS}
fields={searchFields}
field={searchField}
onFieldChange={(f) => onSearchFieldChange(f as SearchField)}
/>
Expand All @@ -122,7 +125,7 @@ export function FilterBar({
className="text-[11px] font-mono text-text-dim hover:text-danger px-1.5 py-0.5 cursor-pointer transition-colors"
onClick={onClear}
>
Clear
{t("common.clear")}
</button>
)}
</div>
Expand Down
15 changes: 9 additions & 6 deletions src/components/FilterSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BottomSheet } from "./BottomSheet";
import { CloseButton } from "./CloseButton";

// Mobile filter trigger: funnel + active count, styled like a dropdown trigger.
export function FiltersButton({ activeCount, onClick }: { activeCount: number; onClick: () => void }) {
const { t } = useTranslation();
const active = activeCount > 0;
return (
<button
Expand All @@ -19,7 +21,7 @@ export function FiltersButton({ activeCount, onClick }: { activeCount: number; o
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M2 3h12l-4.5 5.5V13L6.5 11.5V8.5L2 3z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
Filters
{t("common.filters")}
{active && (
<span className="text-[9px] px-1 rounded-sm bg-primary/15 min-w-[1ch] text-center">{activeCount}</span>
)}
Expand All @@ -33,11 +35,12 @@ export function FilterSheet({ onClose, onClear, children }: {
onClear?: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<BottomSheet onClose={onClose} label="Filters">
<BottomSheet onClose={onClose} label={t("common.filters")}>
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Filters</span>
<CloseButton onClose={onClose} label="Close filters" className="-mr-1" />
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">{t("common.filters")}</span>
<CloseButton onClose={onClose} label={t("common.closeFilters")} className="-mr-1" />
</div>

<div className="flex flex-col gap-3 px-4 py-2 overflow-y-auto">{children}</div>
Expand All @@ -49,7 +52,7 @@ export function FilterSheet({ onClose, onClear, children }: {
onClick={onClear}
className="text-xs font-mono text-text-dim hover:text-danger px-2 py-1.5 cursor-pointer transition-colors"
>
Clear all
{t("common.clearAll")}
</button>
) : (
<span />
Expand All @@ -59,7 +62,7 @@ export function FilterSheet({ onClose, onClear, children }: {
onClick={onClose}
className="text-xs font-mono font-medium text-primary border border-primary-dim bg-primary/6 rounded px-4 py-1.5 cursor-pointer transition-colors hover:bg-primary/10"
>
Done
{t("common.done")}
</button>
</div>
</BottomSheet>
Expand Down
13 changes: 9 additions & 4 deletions src/components/LoadingPill.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { useTranslation } from "react-i18next";

type LoadingNoun = "nodes" | "routes" | "packets" | "messages" | "observers";

interface LoadingPillProps {
loading: boolean;
error?: boolean;
count: number; // rows loaded so far
noun: string; // plural entity name, e.g. "nodes" / "observers"
noun: LoadingNoun;
label?: string; // optional complete localized phrase
position?: string; // corner placement (the parent must be `relative`)
}

// Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while
// pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle.
export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) {
const { t } = useTranslation();
if (!loading && !error) return null;
const tone = loading ? "text-text-muted" : "text-danger";
const dot = loading ? "bg-primary animate-pulse" : "bg-danger";
Expand All @@ -20,10 +25,10 @@ export function LoadingPill({ loading, error, count, noun, label, position = "bo
>
<span className={`size-1.5 rounded-full ${dot}`} aria-hidden />
{label ?? (loading
? `Loading ${noun}… (${count})`
? t(`loadingPill.${noun}.loading`, { count })
: count > 0
? `Some ${noun} failed to load (${count} shown)`
: `Failed to load ${noun}`)}
? t(`loadingPill.${noun}.partial`, { count })
: t(`loadingPill.${noun}.failed`))}
</div>
);
}
Loading
Loading