diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index d7c0e3b26..73f9d4a7e 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -22,7 +22,7 @@ const List = forwardRef( return ( ); diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx index 91d9071cc..f45dec4d6 100644 --- a/client/ui/frontend/src/contexts/NavSectionContext.tsx +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -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; diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx index e6cfb715b..590763633 100644 --- a/client/ui/frontend/src/layouts/AppRightPanel.tsx +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -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 (
{ const ip = status?.local.ip || ""; return ( -
+
{"NetBird"} { + 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 ( + + + + + + 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", + )} + > + e.stopPropagation()}> + + handleSelect(NONE_VALUE)} /> + {hasAny &&
} + {hasAny && ( + + + {exitNodes.map((n) => ( + handleSelect(n.id)} + /> + ))} + + + + + + )} + + + + + + ); +}; + +type TriggerProps = React.ButtonHTMLAttributes & { + title: string; + description: string; + active?: boolean; +}; + +const ExitNodeTriggerCard = forwardRef( + function ExitNodeTriggerCard( + { title, description, disabled, active = false, className, ...props }, + ref, + ) { + return ( + + ); + }, +); + +type NoneRowProps = { + isActive: boolean; + onSelect: () => void; +}; + +const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { + const { t } = useTranslation(); + return ( + + {t("exitNodes.dropdown.noneTitle")} + {isActive && } + + ); +}; + +type ExitNodeRowProps = { + id: string; + label: string; + isActive: boolean; + onSelect: () => void; +}; + +const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( + + {label} + {isActive && } + +); + +const ExitNodeIcon = ({ size, ...props }: LucideProps) => ( + +); diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index 6f2b8018b..8d9787e89 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -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 (
{/* 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 */} -
+
{profileSlot}
-
- {settingsSlot} -
+
{settingsSlot}
); }; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index cb02cc90f..30ce65c74 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -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 */} -
+
+
+ +
{isAdvanced && ( @@ -56,7 +59,11 @@ const AdvancedAppRightPanel = () => { const isConnected = status?.status === "Connected"; return ( - } overlayOpen={selected !== null}> + } + overlayOpen={selected !== null} + className={"m-5 ml-0"} + >
{
{section === "peers" && } {section === "networks" && } - {section === "exitNode" && }
{!isConnected && ( diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index bf9f52edd..d839f04d4 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -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) => ( - -); - export type { NavSection } from "@/contexts/NavSectionContext"; diff --git a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx deleted file mode 100644 index bcc628b86..000000000 --- a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx +++ /dev/null @@ -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(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([]); - 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 ( -
- -
- ); - } - - return ( -
-
-
- setSearch(e.target.value)} - /> -
-
- - - {filtered.length === 0 ? ( - - ) : ( - - )} - - - - - -
- ); -}; - -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 ( - - - {data.map((n) => ( - - ))} - - ); -}; - -type RowProps = { - value: string; - label: string; - first?: boolean; -}; - -const Row = ({ value, label, first }: RowProps) => ( - -
- -
- - - -
-); diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 1f6477077..313e70633 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -58,7 +58,7 @@ export function SettingsAbout() { return (
{"NetBird"} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 65178f738..371167771 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -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", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index de4ec0fe6..d84621812 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -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", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 5aecfe03b..ae2dbcfa5 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -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", diff --git a/client/ui/main.go b/client/ui/main.go index c73133a96..b0c8208b7 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -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: "/", diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index cafc72bb5..e914dbde7 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -45,6 +45,10 @@ const EventSettingsOpen = "netbird:settings:open" // at #181A1D / nb-gray-950, used by AppLayout's 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,