mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 04:21:29 +02:00
add exit node switcher
This commit is contained in:
@@ -22,7 +22,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("w-full flex flex-col gap-1 p-4 pr-0", className)}
|
||||
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
children: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
overlayOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// iOS-style push transition: incoming pane slides in from the right while
|
||||
@@ -16,13 +17,14 @@ const PANEL_TRANSITION = {
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-4",
|
||||
"wails-no-draggable relative m-5",
|
||||
"bg-nb-gray-940 border border-nb-gray-920",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
@@ -343,7 +343,11 @@ export const MainConnectionStatusSwitch = () => {
|
||||
const ip = status?.local.ip || "";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full w-full items-center justify-center gap-4 relative -top-5",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
|
||||
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
215
client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { forwardRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronsUpDown, LucideProps, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const MainExitNodeSwitcher = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const active = exitNodes.find((n) => n.selected) ?? null;
|
||||
const isConnected = status?.status === "Connected";
|
||||
const hasAny = exitNodes.length > 0;
|
||||
const disabled = !isConnected || !hasAny;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = (next: string) => {
|
||||
setOpen(false);
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) void toggleExitNode(active.id, true);
|
||||
return;
|
||||
}
|
||||
if (active && active.id === next) return;
|
||||
void toggleExitNode(next, false);
|
||||
};
|
||||
|
||||
const title = active ? active.id : t("exitNodes.card.title");
|
||||
const description = !hasAny
|
||||
? t("exitNodes.empty.title")
|
||||
: active
|
||||
? t("exitNodes.card.statusActive")
|
||||
: t("exitNodes.card.statusInactive");
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ExitNodeTriggerCard
|
||||
title={title}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
active={!!active}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
side={"top"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Command.List>
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
|
||||
<ScrollArea.Viewport className={"max-h-72 px-1"}>
|
||||
{exitNodes.map((n) => (
|
||||
<ExitNodeRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
label={n.id}
|
||||
isActive={active?.id === n.id}
|
||||
onSelect={() => handleSelect(n.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type TriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
title: string;
|
||||
description: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
function ExitNodeTriggerCard(
|
||||
{ title, description, disabled, active = false, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={"button"}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2.5 pr-5 rounded-xl outline-none text-left",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
disabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-default hover:bg-nb-gray-935 hover:border-nb-gray-900 data-[state=open]:bg-nb-gray-935 data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
active
|
||||
? "bg-green-500/25 text-green-400"
|
||||
: "bg-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<ExitNodeIcon size={14} />
|
||||
</div>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
|
||||
<TruncatedText
|
||||
text={description}
|
||||
className={
|
||||
"block text-[0.85rem] font-medium text-nb-gray-400 truncate max-w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type NoneRowProps = {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Command.Item
|
||||
value={NONE_VALUE}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodeRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
<Command.Item
|
||||
value={id}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-2 pr-3",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
@@ -24,7 +24,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
import {isWindows} from "@/lib/platform.ts";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
@@ -143,21 +143,24 @@ export const MainHeader = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable relative",
|
||||
"flex items-center h-12 top-2.5",
|
||||
"shrink-0 cursor-default wails-draggable relative z-10",
|
||||
"flex items-center h-12 top-3",
|
||||
)}
|
||||
>
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("grid grid-cols-3 items-center shrink-0", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-3 items-center shrink-0",
|
||||
isWindows() ? "w-[364px]" : "w-[380px]",
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className={"absolute right-[0.98rem] top-1/2 -translate-y-1/2"}>
|
||||
{settingsSlot}
|
||||
</div>
|
||||
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
|
||||
import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
|
||||
import { MainHeader } from "@/modules/main/MainHeader.tsx";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
|
||||
@@ -9,7 +10,6 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
@@ -37,8 +37,11 @@ const MainBody = () => {
|
||||
{/* Windows gets a narrower width to compensate for the OS window frame/border that Wails
|
||||
counts differently than macOS, so the visible content area lines up on both platforms.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div className={cn("flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<div className={cn("relative flex flex-col items-center shrink-0 ", isWindows() ? "w-[364px]" : "w-[380px]")}>
|
||||
<MainConnectionStatusSwitch />
|
||||
<div className={"absolute left-5 right-5 bottom-5 wails-no-draggable"}>
|
||||
<MainExitNodeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<NavSectionProvider>
|
||||
@@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => {
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel overlay={<PeerDetailPanel />} overlayOpen={selected !== null}>
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
@@ -68,7 +75,6 @@ const AdvancedAppRightPanel = () => {
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -28,11 +28,6 @@ export const Navigation = () => {
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
},
|
||||
{
|
||||
value: "exitNode",
|
||||
label: t("nav.exitNode.title"),
|
||||
icon: ExitNodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -72,12 +67,4 @@ export const Navigation = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
<SquareArrowUpRight
|
||||
{...props}
|
||||
size={typeof size === "number" ? size - 2 : size}
|
||||
className={cn("rotate-45", props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
export type { NavSection } from "@/contexts/NavSectionContext";
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { mockExitNodes, mockOr } from "@/lib/mock";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { exitNodes: realExitNodes, toggleExitNode } = useNetworks();
|
||||
const exitNodes = mockOr(realExitNodes, mockExitNodes);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it. Mirrors the networks-list behavior so
|
||||
// the optimistic radio flip paints in place instead of the row jumping to
|
||||
// the top.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const byId = new Map(exitNodes.map((n) => [n.id, n]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = exitNodes
|
||||
.filter((n) => !known.has(n.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((n) => n.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [exitNodes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return ordered;
|
||||
return ordered.filter((r) => r.id.toLowerCase().includes(q));
|
||||
}, [ordered, search]);
|
||||
|
||||
if (isConnected && exitNodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={WaypointsIcon}
|
||||
title={t("exitNodes.empty.title")}
|
||||
description={t("exitNodes.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
|
||||
learnMoreTopic={t("nav.exitNode.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodesListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
className={"flex flex-col"}
|
||||
>
|
||||
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
|
||||
{data.map((n) => (
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
const Row = ({ value, label, first }: RowProps) => (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
|
||||
first && "mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<TruncatedText
|
||||
text={label}
|
||||
className={
|
||||
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
"border-nb-gray-700 bg-nb-gray-900",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={"h-2 w-2 rounded-full bg-white"}
|
||||
/>
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
@@ -58,7 +58,7 @@ export function SettingsAbout() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
|
||||
@@ -381,6 +381,11 @@
|
||||
"exitNodes.none": "Keiner",
|
||||
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
|
||||
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Aktiv",
|
||||
"exitNodes.card.statusInactive": "Inaktiv",
|
||||
"exitNodes.dropdown.noneTitle": "Keiner",
|
||||
"exitNodes.dropdown.noneDescription": "Direkte Verbindung ohne Exit Node",
|
||||
|
||||
"quickActions.connect": "Verbinden",
|
||||
"quickActions.disconnect": "Trennen",
|
||||
|
||||
@@ -398,6 +398,11 @@
|
||||
"exitNodes.none": "None",
|
||||
"exitNodes.empty.title": "No exit nodes available",
|
||||
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
|
||||
"exitNodes.card.title": "Exit Node",
|
||||
"exitNodes.card.statusActive": "Active",
|
||||
"exitNodes.card.statusInactive": "Inactive",
|
||||
"exitNodes.dropdown.noneTitle": "None",
|
||||
"exitNodes.dropdown.noneDescription": "Direct connection without an exit node",
|
||||
|
||||
"quickActions.connect": "Connect",
|
||||
"quickActions.disconnect": "Disconnect",
|
||||
|
||||
@@ -381,6 +381,11 @@
|
||||
"exitNodes.none": "Egyik sem",
|
||||
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
|
||||
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
|
||||
"exitNodes.card.title": "Kilépő csomópont",
|
||||
"exitNodes.card.statusActive": "Aktív",
|
||||
"exitNodes.card.statusInactive": "Inaktív",
|
||||
"exitNodes.dropdown.noneTitle": "Egyik sem",
|
||||
"exitNodes.dropdown.noneDescription": "Közvetlen kapcsolat kilépő csomópont nélkül",
|
||||
|
||||
"quickActions.connect": "Csatlakozás",
|
||||
"quickActions.disconnect": "Bontás",
|
||||
|
||||
@@ -316,7 +316,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
Width: initialWidth,
|
||||
Height: 640,
|
||||
Height: services.WindowHeight,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
URL: "/",
|
||||
|
||||
@@ -45,6 +45,10 @@ const EventSettingsOpen = "netbird:settings:open"
|
||||
// at #181A1D / nb-gray-950, used by AppLayout's <html> background).
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29)
|
||||
|
||||
// WindowHeight is the shared frame height for the main window and the
|
||||
// Settings window so the right panel inside both ends up the same size.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21);
|
||||
// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only
|
||||
@@ -192,7 +196,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
Height: 640,
|
||||
Height: WindowHeight,
|
||||
Hidden: true,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
|
||||
Reference in New Issue
Block a user