From 580cfa0dc5941f73db9daeacea40f0eed227963d Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 22 May 2026 09:53:08 +0200 Subject: [PATCH] add default and advanced resize --- client/ui/CLAUDE.md | 2 +- client/ui/frontend/CLAUDE.md | 8 +- client/ui/frontend/src/components/Badge.tsx | 21 +--- .../ui/frontend/src/components/IconButton.tsx | 2 +- .../src/components/ProfileDropdown.tsx | 14 +-- .../frontend/src/components/ToggleSwitch.tsx | 111 +++++++++--------- .../src/layouts/ConnectionStatusSwitch.tsx | 36 ++---- client/ui/frontend/src/layouts/Header.tsx | 27 ++++- client/ui/main.go | 2 +- client/ui/services/windowmanager.go | 2 +- 10 files changed, 105 insertions(+), 120 deletions(-) diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 0ec89ea45..5b670fb1f 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -88,7 +88,7 @@ Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-sid The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`: -- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. +- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. - **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes. - **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up. - **InstallProgress** (`/#/install-progress?version=`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close). The DEV-only "Development" tab has a "Show updating dialog" button that opens this window directly for preview. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 1b08673f9..c18f62bc0 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -31,7 +31,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). The `Profiles` tab (`modules/settings/SettingsProfiles.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")` — `Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. | | `*` | `` | `AppLayout` | Catch-all | -`AppLayout` wraps `Header + ` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route). `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`). +`AppLayout` wraps `Header + ` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route). The view-mode (Default 380×640 vs Advanced 900×640) lives as `useState` inside `Header.tsx`, which calls `Window.SetSize` directly on change — no shared shell context for it. `SettingsLayout` uses the same provider stack minus the `Header`. It also reserves a 38px `wails-draggable` strip at the top so the macOS traffic-light buttons (the window uses `MacTitleBarHiddenInset`) don't overlap content. @@ -75,9 +75,9 @@ State that crosses screens / windows lives in context. Each provider is mounted 3. `available && enforced && installing` — daemon already installing (force-install). The `installing` flip auto-opens `/install-progress` via `WindowManager.OpenInstallProgress`. Dev preview: `SettingsDevelopment` toggles emit `netbird:dev:overrides`, which this provider listens for and overrides `available / enforced / version`. No more module-level `FORCE_*` constants. -### Wide/narrow panel + no client-side persistence +### Default/Advanced view + no client-side persistence -The `expanded` flag (380px ↔ 925px) lives in `AppLayout` as plain `useState(false)` — the only shell-layout knob. `Header.tsx` reads it via props and calls `Window.SetSize(w, 615)`; `Main.tsx` reads it via `MainOutletContext` to mount/unmount the right-side panel. Every app launch starts small. **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`). Nav-item visibility and header buttons are hardcoded to always-render (the old Appearance toggles are gone). +The Header's "more" dropdown owns a `viewMode: "default" | "advanced"` `useState` and calls `Window.SetSize(width, 640)` directly on change. Sizes live in `VIEW_SIZE` at the top of `Header.tsx`: Default = 380×640, Advanced = 900×640 — the 640 height matches the Settings window so chrome height is consistent across surfaces. Every app launch starts in Default (the Go-side main window is created 380×640 in `main.go`). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`). ## Localisation (i18n) @@ -158,7 +158,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = - **Window dragging.** Use class `wails-draggable` on regions that should drag the OS window (the Header, the SettingsLayout title strip, dialog wrappers like `ConfirmDialog`). Use `wails-no-draggable` on interactive children inside a draggable region (buttons, inputs) — otherwise the drag swallows their click. - **Webview asset access.** Background images / fonts go through Vite at build time, so reference them with `import url from "@/assets/.../foo.svg"`. The Wails dev server proxies `/` to Vite, but absolute filesystem paths won't work in either dev or prod. -- **`Window.SetSize(w, h)`.** Called from `Header.tsx` to switch between 380-wide and 925-wide layouts. There's a one-time initial sync on mount so localStorage's `expanded` flag wins over the Go-side default of 925. +- **`Window.SetSize(w, h)`.** Called from `Header.tsx` when the user picks Default (380×640) or Advanced (900×640) in the view-mode dropdown. Height stays 640 in both, matching the Settings window. - **`Browser.OpenURL(url)`.** Used by `SettingsAbout` for legal links and by the `BrowserLogin` page's "Try again". Has a `window.open` fallback in `SettingsAbout` for the case where Wails refuses (non-http schemes are rejected by Wails). ## Things in flight (don't be surprised by) diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index 6ffddb7b8..ca8f9275f 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -2,13 +2,7 @@ import { forwardRef, type ComponentType, type HTMLAttributes } from "react"; import type { LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; -export type BadgeVariant = - | "info" - | "neutral" - | "brand" - | "success" - | "warning" - | "danger"; +export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger"; type Props = HTMLAttributes & { /** Visual color scheme. Defaults to `info` (sky), used as the @@ -34,22 +28,15 @@ const VARIANT_CLASSES: Record = { // lets the small text sit flush in the pill without the line-height padding // inflating it. export const Badge = forwardRef(function Badge( - { - variant = "info", - icon: Icon, - iconSize = 10, - className, - children, - ...rest - }, + { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest }, ref, ) { return ( (function IconButt className={cn( "h-10 w-10 flex items-center justify-center rounded-lg cursor-default outline-none", "text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-900", - "transition-colors duration-150", + "transition-colors duration-150 wails-no-draggable", className, )} {...props} diff --git a/client/ui/frontend/src/components/ProfileDropdown.tsx b/client/ui/frontend/src/components/ProfileDropdown.tsx index 6973f0812..2d8dfedfd 100644 --- a/client/ui/frontend/src/components/ProfileDropdown.tsx +++ b/client/ui/frontend/src/components/ProfileDropdown.tsx @@ -81,7 +81,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { return ( <> - + @@ -91,7 +91,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { collisionPadding={12} onOpenAutoFocus={(e) => e.preventDefault()} className={cn( - "z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg 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", @@ -186,19 +186,19 @@ const ProfileTriggerButton = forwardRef - - + + {name} - + ); }, diff --git a/client/ui/frontend/src/components/ToggleSwitch.tsx b/client/ui/frontend/src/components/ToggleSwitch.tsx index cfa627244..cacdccfed 100644 --- a/client/ui/frontend/src/components/ToggleSwitch.tsx +++ b/client/ui/frontend/src/components/ToggleSwitch.tsx @@ -8,72 +8,67 @@ import { cn } from "@/lib/cn"; type SwitchVariants = VariantProps; const switchVariants = cva("", { - variants: { - size: { - default: "h-[24px] w-[44px]", - small: "h-[18px] w-[36px]", - large: "h-[36px] w-[66px]", + variants: { + size: { + default: "h-[24px] w-[44px]", + small: "h-[18px] w-[36px]", + large: "h-[36px] w-[66px]", + }, + variant: { + default: [ + "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", + ], + "red-green": [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + red: [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + }, + "thumb-size": { + default: "h-5 w-5 data-[state=checked]:translate-x-5", + small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]", + large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[34px]", + }, }, - variant: { - default: [ - "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", - "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", - "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", - "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", - ], - "red-green": [ - "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", - "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", - "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", - "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", - ], - red: [ - "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", - "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", - "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", - "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", - ], - }, - "thumb-size": { - default: "h-5 w-5 data-[state=checked]:translate-x-5", - small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]", - large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[34px]", - }, - }, }); const ToggleSwitch = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - SwitchVariants & { dataCy?: string } ->( - ( - { className, size = "default", variant = "default", dataCy, ...props }, - ref, - ) => ( + React.ElementRef, + React.ComponentPropsWithoutRef & + SwitchVariants & { dataCy?: string } +>(({ className, size = "default", variant = "default", dataCy, ...props }, ref) => ( { - e.stopPropagation(); - props.onClick?.(e); - }} - ref={ref} - > - + {...props} + data-cy={dataCy} + onClick={(e) => { + e.stopPropagation(); + props.onClick?.(e); + }} + ref={ref} + > + - ), -); +)); ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; export { ToggleSwitch }; diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index ec339ee39..4a201f086 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -33,11 +33,7 @@ const STATUS_KEY: Record = { const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; const EVENT_TRIGGER_LOGIN = "trigger-login"; -const NEEDS_LOGIN_STATES = new Set([ - "NeedsLogin", - "SessionExpired", - "LoginFailed", -]); +const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); const errorMessage = formatErrorMessage; @@ -164,10 +160,7 @@ export const ConnectionStatusSwitch = () => { if (action === "disconnect" && daemonState === "Connected") { return ConnectionState.Disconnecting; } - if ( - (action === "connect" || action === "logging-in") && - daemonState !== "Connected" - ) { + if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") { return ConnectionState.Connecting; } switch (daemonState) { @@ -271,11 +264,7 @@ export const ConnectionStatusSwitch = () => { return; } if (action === "disconnect") { - if ( - daemonState === "Idle" || - daemonState === "Disconnected" || - unreachable - ) { + if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) { setAction(null); } } @@ -307,11 +296,9 @@ export const ConnectionStatusSwitch = () => { }; const isTransitioning = - connState === ConnectionState.Connecting || - connState === ConnectionState.Disconnecting; + connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; const isOn = - connState === ConnectionState.Connected || - connState === ConnectionState.Connecting; + connState === ConnectionState.Connected || connState === ConnectionState.Connecting; const showLocal = connState === ConnectionState.Connected; const fqdn = status?.local.fqdn || ""; const ip = status?.local.ip || ""; @@ -321,7 +308,7 @@ export const ConnectionStatusSwitch = () => { {"NetBird"} @@ -330,23 +317,20 @@ export const ConnectionStatusSwitch = () => { checked={isOn} onCheckedChange={handleSwitch} disabled={isTransitioning || unreachable} - className={cn( - unreachable && "opacity-80", - isTransitioning && "animate-pulse", - )} + className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")} />

{t(STATUS_KEY[connState])}

@@ -354,7 +338,7 @@ export const ConnectionStatusSwitch = () => {

diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index 1bee0ac42..69166eabd 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -8,6 +8,7 @@ import { Settings, type LucideIcon, } from "lucide-react"; +import { Window } from "@wailsio/runtime"; import { WindowManager } from "@bindings/services"; import { DropdownMenu, @@ -22,6 +23,14 @@ import { cn } from "@/lib/cn"; type ViewMode = "default" | "advanced"; +// Window dimensions per view. Height matches the Settings window (640) so the +// chrome height is identical across surfaces; width grows from the compact +// 380 default to 900 in advanced. +const VIEW_SIZE: Record = { + default: { width: 380, height: 640 }, + advanced: { width: 900, height: 640 }, +}; + export const Header = () => { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); @@ -38,7 +47,10 @@ export const Header = () => { const selectMode = (mode: ViewMode) => { setMenuOpen(false); + if (mode === viewMode) return; setViewMode(mode); + const { width, height } = VIEW_SIZE[mode]; + void Window.SetSize(width, height).catch(() => {}); }; return ( @@ -54,12 +66,19 @@ export const Header = () => {

-
+
- - + + - +
diff --git a/client/ui/main.go b/client/ui/main.go index 953ea33e6..76ec19714 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -181,7 +181,7 @@ func main() { window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "NetBird", Width: 380, - Height: 590, + Height: 640, Hidden: true, BackgroundColour: application.NewRGB(24, 26, 29), URL: "/", diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index b02d36cac..4e1a12149 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -78,7 +78,7 @@ func (s *WindowManager) OpenSettings(tab string) { URL: startURL, Mac: application.MacWindow{ InvisibleTitleBarHeight: 38, - Backdrop: application.MacBackdropTranslucent, + Backdrop: application.MacBackdropNormal, TitleBar: application.MacTitleBarHiddenInset, CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, },