update peers ui

This commit is contained in:
Eduard Gert
2026-05-27 18:01:06 +02:00
parent ad7d7fa881
commit 09f4109b01
10 changed files with 191 additions and 143 deletions

View File

@@ -36,7 +36,7 @@ export const Navigation = () => {
];
return (
<div className={"wails-no-draggable shrink-0 flex items-stretch"}>
<div className={"wails-no-draggable shrink-0 flex items-stretch "}>
{tabs.map((tab) => {
const isActive = tab.value === section;
const isDisabled = !isConnected && !isActive;
@@ -49,7 +49,7 @@ export const Navigation = () => {
disabled={isDisabled}
className={cn(
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",

View File

@@ -7,12 +7,6 @@ import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import {
formatShortcut,
useKeyboardShortcut,
} from "@/lib/useKeyboardShortcut";
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
import { useNetworks } from "@/modules/networks/NetworksContext";
import { ExitNodesList } from "./ExitNodesList";
@@ -28,11 +22,6 @@ export const ExitNodes = () => {
searchRef.current?.focus();
}, []);
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
searchRef.current?.focus();
searchRef.current?.select();
});
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const matches = exitNodes.filter((r) => {
@@ -64,28 +53,23 @@ export const ExitNodes = () => {
}
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-6"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("exitNodes.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<ExitNodesList
data={filtered}
onToggle={toggleExitNode}
/>
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar

View File

@@ -1,6 +1,13 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SwitchItem } from "@/components/SwitchItem";
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type NetworkFilter = "all" | "active" | "overlapping";
@@ -13,26 +20,74 @@ type Props = {
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: NetworkFilter; label: string }[] = [
{ value: "all", label: t("networks.filter.all") },
{ value: "active", label: t("networks.filter.active") },
{ value: "overlapping", label: t("networks.filter.overlapping") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: NetworkFilter) => {
onChange(v);
setOpen(false);
};
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as NetworkFilter)}
disabled={disabled}
className={"w-full"}
>
{filters.map((f) => (
<SwitchItem key={f.value} value={f.value} className={"flex-1"}>
{f.label}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</SwitchItem>
))}
</SwitchItemGroup>
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-100",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span>
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -7,12 +7,6 @@ import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import {
formatShortcut,
useKeyboardShortcut,
} from "@/lib/useKeyboardShortcut";
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { NetworksList } from "./NetworksList";
import { useNetworks } from "./NetworksContext";
@@ -50,15 +44,7 @@ export const Networks = () => {
searchRef.current?.focus();
}, []);
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
searchRef.current?.focus();
searchRef.current?.select();
});
const overlapGroups = useMemo(
() => buildOverlapMap(networkRoutes),
[networkRoutes],
);
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
const overlapById = useMemo(() => {
const map = new Map<string, string[]>();
@@ -83,9 +69,7 @@ export const Networks = () => {
if (filter === "active" && !r.selected) return false;
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
if (q) {
const haystack = [r.id, r.range, ...r.domains]
.join(" ")
.toLowerCase();
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
@@ -115,33 +99,24 @@ export const Networks = () => {
}
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-6"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
<NetworkFilters
value={filter}
onChange={setFilter}
counts={counts}
/>
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("networks.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<NetworksList
data={filtered}
onToggle={toggleNetwork}
/>
<NetworksList data={filtered} onToggle={toggleNetwork} />
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar

View File

@@ -1,6 +1,13 @@
import { useState } from "react";
import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SwitchItem } from "@/components/SwitchItem";
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
export type StatusFilter = "all" | "online" | "offline";
@@ -13,26 +20,74 @@ type Props = {
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("peers.filter.all") },
{ value: "online", label: t("peers.filter.online") },
{ value: "offline", label: t("peers.filter.offline") },
];
const active = filters.find((f) => f.value === value) ?? filters[0];
const handleSelect = (v: StatusFilter) => {
onChange(v);
setOpen(false);
};
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as StatusFilter)}
disabled={disabled}
className={"w-full"}
>
{filters.map((f) => (
<SwitchItem key={f.value} value={f.value} className={"flex-1"}>
{f.label}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</SwitchItem>
))}
</SwitchItemGroup>
<DropdownMenu key={i18n.language} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-100",
"outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150",
"disabled:opacity-50 disabled:pointer-events-none",
"wails-no-draggable",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<span>
{active.label}{" "}
<span className={"tabular-nums"}>
({counts[active.value]})
</span>
</span>
<ChevronDown
size={14}
className={"text-nb-gray-400 ml-0.5 shrink-0"}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
const checked = f.value === value;
return (
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>
({counts[f.value]})
</span>
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -7,17 +7,11 @@ import { SearchInput } from "@/components/SearchInput";
import { EmptyState } from "@/components/EmptyState";
import { NoResults } from "@/components/NoResults";
import { useStatus } from "@/modules/daemon-status/StatusContext";
import {
formatShortcut,
useKeyboardShortcut,
} from "@/lib/useKeyboardShortcut";
import { PeerFilters, StatusFilter } from "./PeerFilters";
import { PeersList } from "./PeersList";
const isOnline = (connStatus: string) => connStatus === "Connected";
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
@@ -32,11 +26,6 @@ export const Peers = () => {
searchRef.current?.focus();
}, []);
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
searchRef.current?.focus();
searchRef.current?.select();
});
const isConnected = status?.status === "Connected";
const peers = status?.peers ?? [];
@@ -88,31 +77,21 @@ export const Peers = () => {
}
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-6"}>
<SearchInput
ref={searchRef}
placeholder={t("peers.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
shortcut={formatShortcut(SEARCH_SHORTCUT)}
/>
<PeerFilters
value={statusFilter}
onChange={setStatusFilter}
counts={counts}
/>
<div className={"flex flex-col w-full h-full min-h-0"}>
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
<div className={"flex-1 min-w-0"}>
<SearchInput
ref={searchRef}
placeholder={t("peers.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<PeerFilters value={statusFilter} onChange={setStatusFilter} counts={counts} />
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
{filtered.length === 0 ? (
<NoResults />
) : (
<PeersList data={filtered} />
)}
{filtered.length === 0 ? <NoResults /> : <PeersList data={filtered} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}

View File

@@ -28,7 +28,7 @@ export const PeersList = ({ data }: { data: PeerStatus[] }) => {
key={peer.pubKey}
onClick={() => setSelected(peer)}
className={cn(
"group flex items-start gap-2.5 px-7 py-3 min-w-0",
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable cursor-pointer",
)}

View File

@@ -310,7 +310,7 @@
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
"sessionAboutToExpire.logoutFailedTitle": "Abmeldung fehlgeschlagen",
"peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen",
"peers.search.placeholder": "Nach Name oder IP suchen",
"peers.filter.all": "Alle",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
@@ -335,7 +335,7 @@
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass aktiviert",
"networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen",
"networks.search.placeholder": "Nach Netzwerk oder Domain suchen",
"networks.filter.all": "Alle",
"networks.filter.active": "Aktiv",
"networks.filter.overlapping": "Überlappend",

View File

@@ -326,7 +326,7 @@
"sessionAboutToExpire.expired": "Session expired",
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
"peers.search.placeholder": "Search by peer name, DNS or IP address",
"peers.search.placeholder": "Search by name or IP",
"peers.filter.all": "All",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
@@ -351,7 +351,7 @@
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass enabled",
"networks.search.placeholder": "Search by network, range or domain",
"networks.search.placeholder": "Search by network or domain",
"networks.filter.all": "All",
"networks.filter.active": "Active",
"networks.filter.overlapping": "Overlapping",

View File

@@ -310,7 +310,7 @@
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
"sessionAboutToExpire.logoutFailedTitle": "Kijelentkezés sikertelen",
"peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján",
"peers.search.placeholder": "Keresés név vagy IP alapján",
"peers.filter.all": "Összes",
"peers.filter.online": "Online",
"peers.filter.offline": "Offline",
@@ -335,7 +335,7 @@
"peers.details.p2p": "P2P",
"peers.details.rosenpass": "Rosenpass engedélyezve",
"networks.search.placeholder": "Keresés hálózat, tartomány vagy domain alapján",
"networks.search.placeholder": "Keresés hálózat vagy domain alapján",
"networks.filter.all": "Összes",
"networks.filter.active": "Aktív",
"networks.filter.overlapping": "Átfedő",