From 51b243bdfa914b0e5764405def86f115b729e877 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 28 May 2026 16:26:13 +0200 Subject: [PATCH] remove unused stuff, refactor frontend folder structure --- client/ui/CLAUDE.md | 14 +- client/ui/frontend/CLAUDE.md | 76 ++++-- client/ui/frontend/package.json | 1 - client/ui/frontend/pnpm-lock.yaml | 33 --- client/ui/frontend/src/app.tsx | 27 +- client/ui/frontend/src/components/Avatar.tsx | 40 --- .../frontend/src/components/BottomSheet.tsx | 60 ----- client/ui/frontend/src/components/Card.tsx | 14 -- .../frontend/src/components/CardNavItem.tsx | 86 ------- .../ui/frontend/src/components/CardSelect.tsx | 76 ------ .../ui/frontend/src/components/HoverCard.tsx | 34 --- .../LanguagePicker.tsx | 4 +- .../ManagementServerSwitch.tsx | 6 +- .../frontend/src/components/StatusPanel.tsx | 48 ---- client/ui/frontend/src/components/Switch.tsx | 42 ---- client/ui/frontend/src/components/Tabs.tsx | 40 --- .../src/components/{ => buttons}/Button.tsx | 6 +- .../components/{ => buttons}/IconButton.tsx | 0 .../components/{ => dialog}/ConfirmDialog.tsx | 0 .../src/components/{ => dialog}/Dialog.tsx | 5 +- .../components/{ => dialog}/DialogActions.tsx | 0 .../{ => dialog}/DialogDescription.tsx | 0 .../components/{ => dialog}/DialogHeading.tsx | 0 .../empty-state}/DaemonUnavailableOverlay.tsx | 4 +- .../{ => empty-state}/EmptyState.tsx | 2 +- .../{ => empty-state}/NoResults.tsx | 0 .../{ => empty-state}/NotConnectedState.tsx | 0 .../src/components/{ => inputs}/Input.tsx | 2 +- .../components/{ => inputs}/SearchInput.tsx | 0 .../{ => switches}/FancyToggleSwitch.tsx | 6 +- .../components/{ => switches}/SwitchItem.tsx | 2 +- .../{ => switches}/SwitchItemGroup.tsx | 0 .../{ => switches}/ToggleSwitch.tsx | 0 .../components/{ => typography}/HelpText.tsx | 0 .../src/components/{ => typography}/Label.tsx | 0 .../ClientVersionContext.tsx | 60 +---- .../DebugBundleContext.tsx} | 35 ++- .../NavSectionContext.tsx} | 0 .../networks => contexts}/NetworksContext.tsx | 2 +- .../peers => contexts}/PeerDetailContext.tsx | 0 .../profile => contexts}/ProfileContext.tsx | 0 .../settings => contexts}/SettingsContext.tsx | 6 +- .../StatusContext.tsx | 2 +- .../ViewModeContext.tsx} | 0 .../src/{lib => hooks}/useAutoSizeWindow.ts | 0 .../src/{lib => hooks}/useKeyboardShortcut.ts | 0 .../settings => hooks}/useManagementUrl.ts | 2 +- client/ui/frontend/src/layouts/AppLayout.tsx | 25 +- .../{MainRightSide.tsx => AppRightPanel.tsx} | 2 +- client/ui/frontend/src/layouts/Main.tsx | 78 ------ .../frontend/src/layouts/SettingsLayout.tsx | 36 --- client/ui/frontend/src/lib/color.ts | 17 -- .../peers/format.ts => lib/formatters.ts} | 0 ...sDialog.tsx => UpdateInProgressDialog.tsx} | 14 +- .../modules/auto-update/UpdateVersionCard.tsx | 4 +- .../debug-bundle/DebugBundleContext.tsx | 16 -- .../debug-bundle/useDebugBundleContext.ts | 12 - .../src/modules/exit-nodes/ExitNodesList.tsx | 77 ------ .../LoginWaitingForBrowserDialog.tsx} | 14 +- .../main/MainConnectionStatusSwitch.tsx} | 8 +- .../main/MainHeader.tsx} | 12 +- .../ui/frontend/src/modules/main/MainPage.tsx | 89 +++++++ .../main/advanced}/Navigation.tsx | 6 +- .../advanced}/exit-nodes/ExitNodes.tsx | 86 ++++++- .../advanced}/networks/NetworkFilters.tsx | 0 .../advanced/networks/Networks.tsx} | 232 ++++++++++++++++-- .../advanced/peers/PeerDetailPanel.tsx} | 124 +++++++++- .../{ => main/advanced}/peers/PeerFilters.tsx | 0 .../{ => main/advanced}/peers/Peers.tsx | 96 +++++++- .../src/modules/networks/Networks.tsx | 202 --------------- .../src/modules/peers/PeerDetailPanel.tsx | 122 --------- .../frontend/src/modules/peers/PeersList.tsx | 85 ------- client/ui/frontend/src/modules/peers/peers.md | 48 ---- .../profiles}/ProfileAvatar.tsx | 0 .../profiles/ProfileCreationModal.tsx} | 8 +- .../profiles}/ProfileDropdown.tsx | 8 +- .../ProfilesTab.tsx} | 14 +- .../SessionAboutToExpireDialog.tsx | 12 +- .../SessionExpiredDialog.tsx | 12 +- .../src/modules/settings/Settings.tsx | 98 -------- .../src/modules/settings/SettingsAbout.tsx | 2 +- .../src/modules/settings/SettingsAdvanced.tsx | 10 +- .../modules/settings/SettingsDevelopment.tsx | 84 ------- .../src/modules/settings/SettingsGeneral.tsx | 18 +- ...ionTriggers.tsx => SettingsNavigation.tsx} | 12 +- .../src/modules/settings/SettingsNetwork.tsx | 4 +- .../src/modules/settings/SettingsPage.tsx | 110 +++++++++ .../src/modules/settings/SettingsSSH.tsx | 10 +- .../src/modules/settings/SettingsSecurity.tsx | 4 +- .../SettingsSkeleton.tsx} | 2 +- .../settings/SettingsTroubleshooting.tsx | 20 +- client/ui/services/windowmanager.go | 14 +- 92 files changed, 953 insertions(+), 1629 deletions(-) delete mode 100644 client/ui/frontend/src/components/Avatar.tsx delete mode 100644 client/ui/frontend/src/components/BottomSheet.tsx delete mode 100644 client/ui/frontend/src/components/Card.tsx delete mode 100644 client/ui/frontend/src/components/CardNavItem.tsx delete mode 100644 client/ui/frontend/src/components/CardSelect.tsx delete mode 100644 client/ui/frontend/src/components/HoverCard.tsx rename client/ui/frontend/src/{modules/settings => components}/LanguagePicker.tsx (99%) rename client/ui/frontend/src/{modules/settings => components}/ManagementServerSwitch.tsx (81%) delete mode 100644 client/ui/frontend/src/components/StatusPanel.tsx delete mode 100644 client/ui/frontend/src/components/Switch.tsx delete mode 100644 client/ui/frontend/src/components/Tabs.tsx rename client/ui/frontend/src/components/{ => buttons}/Button.tsx (97%) rename client/ui/frontend/src/components/{ => buttons}/IconButton.tsx (100%) rename client/ui/frontend/src/components/{ => dialog}/ConfirmDialog.tsx (100%) rename client/ui/frontend/src/components/{ => dialog}/Dialog.tsx (96%) rename client/ui/frontend/src/components/{ => dialog}/DialogActions.tsx (100%) rename client/ui/frontend/src/components/{ => dialog}/DialogDescription.tsx (100%) rename client/ui/frontend/src/components/{ => dialog}/DialogHeading.tsx (100%) rename client/ui/frontend/src/{modules/daemon-status => components/empty-state}/DaemonUnavailableOverlay.tsx (94%) rename client/ui/frontend/src/components/{ => empty-state}/EmptyState.tsx (97%) rename client/ui/frontend/src/components/{ => empty-state}/NoResults.tsx (100%) rename client/ui/frontend/src/components/{ => empty-state}/NotConnectedState.tsx (100%) rename client/ui/frontend/src/components/{ => inputs}/Input.tsx (99%) rename client/ui/frontend/src/components/{ => inputs}/SearchInput.tsx (100%) rename client/ui/frontend/src/components/{ => switches}/FancyToggleSwitch.tsx (91%) rename client/ui/frontend/src/components/{ => switches}/SwitchItem.tsx (94%) rename client/ui/frontend/src/components/{ => switches}/SwitchItemGroup.tsx (100%) rename client/ui/frontend/src/components/{ => switches}/ToggleSwitch.tsx (100%) rename client/ui/frontend/src/components/{ => typography}/HelpText.tsx (100%) rename client/ui/frontend/src/components/{ => typography}/Label.tsx (100%) rename client/ui/frontend/src/{modules/auto-update => contexts}/ClientVersionContext.tsx (69%) rename client/ui/frontend/src/{modules/debug-bundle/useDebugBundle.ts => contexts/DebugBundleContext.tsx} (87%) rename client/ui/frontend/src/{lib/navSection.tsx => contexts/NavSectionContext.tsx} (100%) rename client/ui/frontend/src/{modules/networks => contexts}/NetworksContext.tsx (99%) rename client/ui/frontend/src/{modules/peers => contexts}/PeerDetailContext.tsx (100%) rename client/ui/frontend/src/{modules/profile => contexts}/ProfileContext.tsx (100%) rename client/ui/frontend/src/{modules/settings => contexts}/SettingsContext.tsx (96%) rename client/ui/frontend/src/{modules/daemon-status => contexts}/StatusContext.tsx (97%) rename client/ui/frontend/src/{lib/viewMode.tsx => contexts/ViewModeContext.tsx} (100%) rename client/ui/frontend/src/{lib => hooks}/useAutoSizeWindow.ts (100%) rename client/ui/frontend/src/{lib => hooks}/useKeyboardShortcut.ts (100%) rename client/ui/frontend/src/{modules/settings => hooks}/useManagementUrl.ts (98%) rename client/ui/frontend/src/layouts/{MainRightSide.tsx => AppRightPanel.tsx} (94%) delete mode 100644 client/ui/frontend/src/layouts/Main.tsx delete mode 100644 client/ui/frontend/src/layouts/SettingsLayout.tsx delete mode 100644 client/ui/frontend/src/lib/color.ts rename client/ui/frontend/src/{modules/peers/format.ts => lib/formatters.ts} (100%) rename client/ui/frontend/src/modules/auto-update/{InstallProgressDialog.tsx => UpdateInProgressDialog.tsx} (93%) delete mode 100644 client/ui/frontend/src/modules/debug-bundle/DebugBundleContext.tsx delete mode 100644 client/ui/frontend/src/modules/debug-bundle/useDebugBundleContext.ts delete mode 100644 client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx rename client/ui/frontend/src/modules/{authentication/WaitingForBrowserDialog.tsx => login/LoginWaitingForBrowserDialog.tsx} (87%) rename client/ui/frontend/src/{layouts/ConnectionStatusSwitch.tsx => modules/main/MainConnectionStatusSwitch.tsx} (98%) rename client/ui/frontend/src/{layouts/Header.tsx => modules/main/MainHeader.tsx} (94%) create mode 100644 client/ui/frontend/src/modules/main/MainPage.tsx rename client/ui/frontend/src/{layouts => modules/main/advanced}/Navigation.tsx (93%) rename client/ui/frontend/src/modules/{ => main/advanced}/exit-nodes/ExitNodes.tsx (61%) rename client/ui/frontend/src/modules/{ => main/advanced}/networks/NetworkFilters.tsx (100%) rename client/ui/frontend/src/modules/{networks/NetworksList.tsx => main/advanced/networks/Networks.tsx} (52%) rename client/ui/frontend/src/modules/{peers/PeerDetails.tsx => main/advanced/peers/PeerDetailPanel.tsx} (60%) rename client/ui/frontend/src/modules/{ => main/advanced}/peers/PeerFilters.tsx (100%) rename client/ui/frontend/src/modules/{ => main/advanced}/peers/Peers.tsx (51%) delete mode 100644 client/ui/frontend/src/modules/networks/Networks.tsx delete mode 100644 client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx delete mode 100644 client/ui/frontend/src/modules/peers/PeersList.tsx delete mode 100644 client/ui/frontend/src/modules/peers/peers.md rename client/ui/frontend/src/{components => modules/profiles}/ProfileAvatar.tsx (100%) rename client/ui/frontend/src/{components/NewProfileModal.tsx => modules/profiles/ProfileCreationModal.tsx} (91%) rename client/ui/frontend/src/{components => modules/profiles}/ProfileDropdown.tsx (97%) rename client/ui/frontend/src/modules/{settings/SettingsProfiles.tsx => profiles/ProfilesTab.tsx} (95%) rename client/ui/frontend/src/modules/{authentication => session}/SessionAboutToExpireDialog.tsx (93%) rename client/ui/frontend/src/modules/{authentication => session}/SessionExpiredDialog.tsx (81%) delete mode 100644 client/ui/frontend/src/modules/settings/Settings.tsx delete mode 100644 client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx rename client/ui/frontend/src/modules/settings/{SettingsNavigationTriggers.tsx => SettingsNavigation.tsx} (85%) create mode 100644 client/ui/frontend/src/modules/settings/SettingsPage.tsx rename client/ui/frontend/src/modules/{skeletons/SkeletonSettings.tsx => settings/SettingsSkeleton.tsx} (95%) diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 62963fa87..15e1a8b69 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -34,7 +34,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr | `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. | | `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. | | `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). | -| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. | +| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. | | `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | | `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. | | `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. | @@ -59,7 +59,7 @@ Daemon connection status strings (`services/peers.go`) mirror `internal.Status*` ## Profile switching -`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profile/ProfileContext.tsx`'s `switchProfile`, which `modules/settings/SettingsProfiles.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions. +`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profiles/ProfileContext.tsx`'s `switchProfile`, which `modules/profiles/ProfilesTab.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions. Reconnect policy (driven by `prevStatus` from `Peers.Get`): @@ -88,10 +88,10 @@ 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 (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. +- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/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** (`ProfilesTab.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** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/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** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/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 no triggers wired — daemon-status integration is a follow-up. +- **InstallProgress** (`/#/dialog/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). All five auxiliary windows are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for auxiliaries. @@ -134,7 +134,7 @@ The tray uses Wails' built-in `notifications` service. One `notifications.Notifi ### Profile switching invariants -`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/settings/SettingsProfiles.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above. +`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above. **Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index e8e7d7c9b..e03e33450 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -22,21 +22,53 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | Path | Component | Layout | Where it opens | |---|---|---|---| -| `/` | `Main` | `AppLayout` | Main window default route | -| `/browser-login` | `WaitingForBrowserDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) | -| `/install-progress` | `InstallProgressDialog` (modules/auto-update) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). Dev "Show updating dialog" button in `SettingsDevelopment` opens it directly. | -| `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | -| `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | -| `/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. | +| `/` | `MainPage` (modules/main/) | `AppLayout` | Main window default route | +| `/dialog/browser-login` | `LoginWaitingForBrowserDialog` (modules/login/) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) | +| `/dialog/install-progress` | `UpdateInProgressDialog` (modules/auto-update/) | none | Auxiliary window (Go `WindowManager.OpenInstallProgress(version)`, always-on-top). Owns the install-result polling + 5s daemon-down-grace; calls `Update.Quit()` on success. Opened by `ClientVersionContext.triggerUpdate` (enforced user-driven branch) and on the `installing` flip from `netbird:update:state` (force-install branch). | +| `/dialog/session-expired` | `SessionExpiredDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | +| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | +| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.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). 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. +In `app.tsx` the four dialog routes are nested under a parent `` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/`. The `dialog` group has no shared layout component; it's purely a URL grouping. -`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. +`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders ``. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/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). + +Page-specific chrome lives next to the page, not in the layout: +- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only: + - `Header` reads `useViewMode` (view-mode dropdown) and `useClientVersion` (update badge). + - `ViewModeProvider` wraps the whole of `Main` because both `Header` and `MainBody` read view mode. It calls `Window.SetSize` on the current Wails window, so it must not be visible to the Settings window. + - `NavSectionProvider` is mounted only inside the advanced-mode branch (`MainBody → AdvancedRightPanel`) — the default-mode view has no Peers/Resources/Exit Nodes tabs and no consumer of `useNavSection`. Default mode therefore skips the provider entirely. + - `Header.tsx`, `Navigation.tsx`, and `ConnectionStatusSwitch.tsx` are siblings of `Main.tsx` in `pages/main/` because nothing else uses them. +- **`pages/Settings.tsx`** owns the `h-12` `wails-draggable` strip at the top (so the macOS traffic-light buttons that float over the `MacTitleBarHiddenInset` window don't overlap content), then renders the vertical tabs — no view-mode, no nav, no header. ## Directory layout (src/) -`modules//` owns the polished UI — both the AppLayout-shell-driven views and the standalone auxiliary windows (e.g. `modules/authentication/WaitingForBrowserDialog.tsx`, `modules/auto-update/InstallProgressDialog.tsx`). `screens/` is a residual legacy bucket — don't add new code there. +- `app.tsx` — root render + route table. The canonical registry of every route; scan this file to enumerate pages. +- `layouts/AppLayout.tsx` — the router-level layout. Mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) and renders ``. (`layouts/` also holds `AppRightPanel.tsx`, see below.) +- `modules//` — every feature owns its own folder: page entry (named `Page.tsx`), local components, and everything else it needs: + - `modules/main/` — `MainPage.tsx` + main-window chrome (`Header.tsx`, `ConnectionStatusSwitch.tsx`). + - `modules/main/advanced/` — advanced-mode-only surfaces. `Navigation.tsx` plus the three feature sub-modules whose tabs only render here: `peers/`, `networks/`, `exit-nodes/`. + - `modules/settings/` — `SettingsPage.tsx`, shared helpers (`SettingsSection.tsx`, `SettingsNavigation.tsx`, `SettingsSkeleton.tsx`), and all tab files flat (`SettingsGeneral`, `SettingsNetwork`, `SettingsSSH`, `SettingsSecurity`, `SettingsAdvanced`, `SettingsTroubleshooting`, `SettingsAbout`, `SettingsAccent`). `ManagementServerSwitch` and `LanguagePicker` are shared in `components/`; `useManagementUrl` is in `hooks/`. + - `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window). + - `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows). + - `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`. + - `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. + + Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise. +- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`." +- `components/` — presentational primitives, no domain coupling. Grouped by family: + - `components/buttons/` — `Button`, `IconButton`. + - `components/inputs/` — `Input`, `SearchInput`. + - `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`. + - `components/switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`. + - `components/typography/` — `Label`, `HelpText`. + - `components/empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`. + - Flat at root: `Badge.tsx`, `CopyToClipboard.tsx`, `DropdownMenu.tsx`, `SquareIcon.tsx`, `Tooltip.tsx`, `VerticalTabs.tsx` (one-of-a-kind primitives). +- `layouts/` — `AppLayout.tsx` (the only router-level layout) plus the shared content shell `AppRightPanel.tsx` used by both `MainPage` and `SettingsPage`. +- `hooks/` — reusable React hooks (`useAutoSizeWindow.ts`, `useKeyboardShortcut.ts`). +- `lib/` — pure utilities (no JSX, no React state): `cn.ts`, `errors.ts`, `formatters.ts` (byte/latency/relative-time helpers), `i18n.ts`, `welcome.ts`. +- `assets/` — fonts, logos, flags. `screens/` is a residual legacy bucket — don't add new code there. ## Wails event bus @@ -44,15 +76,14 @@ Subscribe with `Events.On(name, handler)`. The handler receives `{ data: `. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down. +- **`useStatus`** (`contexts/StatusContext.tsx`) — `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`. The provider owns a single `Peers.Get()` + `netbird:status` subscription and renders ``. `refresh()` after Connect/Disconnect to dodge a few hundred ms of event-stream lag. Other contexts (e.g. `ProfileContext`) read the boolean flags to skip RPCs while the daemon socket is down. -- **`ProfileContext`** (`modules/profile/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`. +- **`ProfileContext`** (`modules/profiles/`) — `username`, `activeProfile`, `profiles`, plus `refresh` / `switchProfile` / `addProfile` / `removeProfile` / `logoutProfile`. `switchProfile` delegates to `ProfileSwitcher.SwitchActive` (the Go-side single source of truth — drives the optimistic-Connecting paint and `Peers` suppression). The other methods are thin wrappers over `Profiles.*` / `Connection.Logout` plus a `refresh()`. -- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `` while `config === null` so tabs never see null. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"`. +- **`SettingsContext`** (`modules/settings/`) — `setField` / `saveField` / `saveFields` / `saveNow` over `SettingsSvc.GetConfig|SetConfig` with 400ms debounce. Renders `` while `config === null` so tabs never see null. **PSK mask quirk:** `GetConfig` returns existing PSKs as `"**********"`; sending the mask back round-trips it into storage and `wgtypes.ParseKey` fails on the next connect. `save` drops the field when it equals `"**********"`. -- **`DebugBundleProvider` + `useDebugBundle`** (`modules/debug-bundle/`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`. +- **`DebugBundleProvider` + `useDebugBundle`** (`contexts/DebugBundleContext.tsx`) — stages: `idle → preparing-trace → reconnecting → capturing → restoring-level → bundling → uploading → done`. Cancellable via `AbortController` at any stage; cancel restores the original log level best-effort. Wrapped in a context so the troubleshooting tab keeps stage across navigation. Upload URL is the hardcoded `NETBIRD_UPLOAD_URL`. - **`ClientVersionContext`** (`modules/auto-update/`) — seeds from `Update.GetState()` and subscribes to `netbird:update:state`; exposes `{ updateAvailable, updateVersion, enforced, installing, triggerUpdate, updating }`. **Three branches**: 1. `available && !enforced` — download-only. `UpdateVersionCard` shows "Version X is available for download" + "Download installer" → opens GitHub releases. 2. `available && enforced && !installing` — user-driven enforced. `UpdateVersionCard` shows "Version X is available for install" + "Install now" → `triggerUpdate` opens `/install-progress` window then calls `Update.Trigger()`. 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. ### Default/Advanced view + no client-side persistence @@ -121,7 +151,7 @@ Compare against the variable, never against an English literal. **Adding a language.** Drop `client/ui/i18n/locales//common.json` and append the row to `client/ui/i18n/locales/_index.json`. Also drop the matching `.svg` into `src/assets/flags/1x1/` — source those from the NetBird dashboard repo's same-name folder so the icon set stays consistent: https://github.com/netbirdio/dashboard/tree/main/public/assets/flags/1x1 . **Only check in flags for languages we actually ship** — `LanguagePicker.tsx` eager-globs that directory at build time, so every SVG in it gets bundled into the Wails app whether referenced or not. `src/lib/i18n.ts` discovers bundles via `import.meta.glob('../../../i18n/locales/*/common.json', { eager: true })` (the locales tree lives outside `frontend/`, so `vite.config.ts` whitelists the parent dir under `server.fs.allow`), so no code change is needed to wire the new locale in. Vite still inlines each bundle at build time, same chunk shape as static imports. The Go side reads the same tree (embedded via `client/ui/main.go`'s `embed.FS`), so the tray menu localises automatically off the same files. -**Language picker.** `src/modules/settings/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to. +**Language picker.** `src/components/LanguagePicker.tsx` is mounted inside the Language section of `SettingsGeneral.tsx`. It populates from `I18n.Languages()` (matches `_index.json`) and calls `Preferences.SetLanguage(code)` on selection. The preference write triggers `netbird:preferences:changed`, which both the local i18next instance and every other open window listen to. **What gets translated.** Every user-facing string in the polished AppLayout/Settings/Update/BrowserLogin/SessionExpired/Peers surfaces. Don't add hard-coded user-facing English to new code — add the key, then `t()`. Internal log strings, dev-only forced-state strings in `ClientVersionContext`, and the `Update failed` fallback fed into `classifyError()` (which then renders a translated description) are not translated. @@ -132,7 +162,7 @@ The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlig 1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user. 2. If the daemon needs SSO (`needsSsoLogin`): - `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window (Hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`). - - `WaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top. + - `LoginWaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top. - `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first. - On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`). 3. `Connection.Up({})` to bring the new session up. @@ -162,8 +192,8 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background = ## Things in flight (don't be surprised by) -- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `MainRightSide` and is what the user sees today; the real-data one isn't wired into the route table. -- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. +- **`screens/Peers.tsx`** uses live `Peers.Get` data. **`modules/peers/Peers.tsx`** uses `mockPeers.ts`. The mock-driven one is mounted under `Main.tsx`'s `AppRightPanel` and is what the user sees today; the real-data one isn't wired into the route table. +- **`modules/session/SessionExpiredDialog.tsx`** and **`modules/session/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. No triggers wired today — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`. ## Wails Go API reference diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 42591199d..1ad46c912 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -16,7 +16,6 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index 13c96f045..a4412ca26 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-hover-card': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -536,19 +533,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.15': - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -2018,23 +2002,6 @@ snapshots: '@types/react': 18.3.29 '@types/react-dom': 18.3.7(@types/react@18.3.29) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) - '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index ab3129c6b..0796ab491 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -2,17 +2,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; -import SessionExpiredDialog from "@/modules/authentication/SessionExpiredDialog.tsx"; -import SessionAboutToExpireDialog from "@/modules/authentication/SessionAboutToExpireDialog.tsx"; -import InstallProgressDialog from "@/modules/auto-update/InstallProgressDialog.tsx"; +import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx"; +import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx"; +import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; import { AppLayout } from "@/layouts/AppLayout.tsx"; -import { SettingsLayout } from "@/layouts/SettingsLayout.tsx"; -import { Main } from "@/layouts/Main.tsx"; -import { Settings } from "@/modules/settings/Settings.tsx"; +import { MainPage } from "@/modules/main/MainPage.tsx"; +import { SettingsPage } from "@/modules/settings/SettingsPage.tsx"; import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; -import WaitingForBrowserDialog from "@/modules/authentication/WaitingForBrowserDialog.tsx"; +import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; import { initI18n } from "@/lib/i18n"; welcome(); @@ -30,15 +29,15 @@ initI18n() - } /> - } /> - } /> - } /> - }> - } /> + + } /> + } /> + } /> + } /> }> - } /> + } /> + } /> } diff --git a/client/ui/frontend/src/components/Avatar.tsx b/client/ui/frontend/src/components/Avatar.tsx deleted file mode 100644 index 5314db092..000000000 --- a/client/ui/frontend/src/components/Avatar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ButtonHTMLAttributes, forwardRef } from "react"; -import { generateColorFromString } from "@/lib/color"; -import { cn } from "@/lib/cn"; - -type Props = ButtonHTMLAttributes & { - name?: string; - size?: number; -}; - -export const Avatar = forwardRef(function Avatar( - { name = "", size = 28, className, type = "button", ...props }, - ref, -) { - const initial = (name.trim().charAt(0) || "?").toUpperCase(); - const color = generateColorFromString(name); - - return ( - - ); -}); diff --git a/client/ui/frontend/src/components/BottomSheet.tsx b/client/ui/frontend/src/components/BottomSheet.tsx deleted file mode 100644 index b3e5acb42..000000000 --- a/client/ui/frontend/src/components/BottomSheet.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ReactNode, useEffect } from "react"; -import { createPortal } from "react-dom"; -import { AnimatePresence, motion } from "framer-motion"; -import { cn } from "@/lib/cn"; - -type Props = { - open: boolean; - onOpenChange: (open: boolean) => void; - children?: ReactNode; - className?: string; -}; - -export const BottomSheet = ({ open, onOpenChange, children, className }: Props) => { - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onOpenChange(false); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, onOpenChange]); - - return createPortal( - - {open && ( -
- onOpenChange(false)} - /> - -
-
-
-
{children}
- -
- )} - , - document.body, - ); -}; diff --git a/client/ui/frontend/src/components/Card.tsx b/client/ui/frontend/src/components/Card.tsx deleted file mode 100644 index 69dc33a8e..000000000 --- a/client/ui/frontend/src/components/Card.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { HTMLAttributes } from "react"; -import { cn } from "../lib/cn"; - -export function Card({ className, ...rest }: HTMLAttributes) { - return ( -
- ); -} diff --git a/client/ui/frontend/src/components/CardNavItem.tsx b/client/ui/frontend/src/components/CardNavItem.tsx deleted file mode 100644 index a1d1c22d3..000000000 --- a/client/ui/frontend/src/components/CardNavItem.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - ButtonHTMLAttributes, - ComponentType, - forwardRef, - ReactNode, -} from "react"; -import { LucideProps } from "lucide-react"; -import { cn } from "@/lib/cn"; - -type Props = ButtonHTMLAttributes & { - icon?: ComponentType; - iconNode?: ReactNode; - title: string; - description?: string; - active?: boolean; - iconSize?: number; -}; - -export const CardNavItem = forwardRef( - function CardNavItem( - { - icon: Icon, - iconNode, - title, - description, - active = false, - iconSize = 15, - className, - type = "button", - ...props - }, - ref, - ) { - return ( - - ); - }, -); diff --git a/client/ui/frontend/src/components/CardSelect.tsx b/client/ui/frontend/src/components/CardSelect.tsx deleted file mode 100644 index 31a2bed38..000000000 --- a/client/ui/frontend/src/components/CardSelect.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as RadioGroup from "@radix-ui/react-radio-group"; -import { CheckIcon } from "lucide-react"; -import { ReactNode } from "react"; -import { cn } from "@/lib/cn"; - -type RootProps = { - value: string; - onChange: (value: string) => void; - children: ReactNode; - className?: string; -}; - -const Root = ({ value, onChange, children, className }: RootProps) => { - return ( - - {children} - - ); -}; - -type OptionProps = { - value: string; - title: string; - description?: string; - preview?: ReactNode; - className?: string; -}; - -const Option = ({ value, title, description, preview, className }: OptionProps) => { - return ( - - - - - - -
- {preview} -
-

{title}

- {description && ( -

- {description} -

- )} -
- ); -}; - -export const CardSelect = Object.assign(Root, { Option }); diff --git a/client/ui/frontend/src/components/HoverCard.tsx b/client/ui/frontend/src/components/HoverCard.tsx deleted file mode 100644 index f87f3f09c..000000000 --- a/client/ui/frontend/src/components/HoverCard.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; -import * as React from "react"; -import { cn } from "@/lib/cn"; - -const HoverCard = HoverCardPrimitive.Root; -const HoverCardTrigger = HoverCardPrimitive.Trigger; -const HoverCardPortal = HoverCardPrimitive.Portal; - -const HoverCardContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "start", sideOffset = 6, ...props }, ref) => ( - - - -)); -HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; - -export { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger }; diff --git a/client/ui/frontend/src/modules/settings/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx similarity index 99% rename from client/ui/frontend/src/modules/settings/LanguagePicker.tsx rename to client/ui/frontend/src/components/LanguagePicker.tsx index b49957e59..ea9b93adc 100644 --- a/client/ui/frontend/src/modules/settings/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -7,8 +7,8 @@ import { Dialogs } from "@wailsio/runtime"; import { CheckIcon, ChevronDown, Search } from "lucide-react"; import { Preferences } from "@bindings/services"; import { LanguageCode, type Language } from "@bindings/i18n/models.js"; -import { HelpText } from "@/components/HelpText"; -import { Label } from "@/components/Label"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; import { formatErrorMessage } from "@/lib/errors"; diff --git a/client/ui/frontend/src/modules/settings/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx similarity index 81% rename from client/ui/frontend/src/modules/settings/ManagementServerSwitch.tsx rename to client/ui/frontend/src/components/ManagementServerSwitch.tsx index bd6af06a3..5a31f68ad 100644 --- a/client/ui/frontend/src/modules/settings/ManagementServerSwitch.tsx +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import netbirdLogo from "@/assets/logos/netbird.svg"; -import { SwitchItem } from "@/components/SwitchItem"; -import { SwitchItemGroup } from "@/components/SwitchItemGroup"; -import { ManagementMode } from "@/modules/settings/useManagementUrl.ts"; +import { SwitchItem } from "@/components/switches/SwitchItem"; +import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup"; +import { ManagementMode } from "@/hooks/useManagementUrl.ts"; type Props = { value: ManagementMode; diff --git a/client/ui/frontend/src/components/StatusPanel.tsx b/client/ui/frontend/src/components/StatusPanel.tsx deleted file mode 100644 index 937157201..000000000 --- a/client/ui/frontend/src/components/StatusPanel.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactNode } from "react"; -import { Check, Loader2, XCircle } from "lucide-react"; -import { cn } from "@/lib/cn"; - -type Variant = "loading" | "success" | "error"; - -type Props = { - variant: Variant; - title: ReactNode; - description?: ReactNode; - children?: ReactNode; - actions?: ReactNode; -}; - -const VARIANTS: Record = { - loading: { - icon: , - className: "bg-nb-gray-100", - }, - success: { - icon: , - className: "bg-green-500", - }, - error: { - icon: , - className: "bg-red-500", - }, -}; - -export function StatusPanel({ variant, title, description, children, actions }: Props) { - const { icon, className } = VARIANTS[variant]; - return ( -
-
- {icon} -
- -
-

{title}

- {description &&

{description}

} -
- - {children &&
{children}
} - - {actions &&
{actions}
} -
- ); -} diff --git a/client/ui/frontend/src/components/Switch.tsx b/client/ui/frontend/src/components/Switch.tsx deleted file mode 100644 index a01c99862..000000000 --- a/client/ui/frontend/src/components/Switch.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from "../lib/cn"; - -interface Props { - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - label?: string; - description?: string; -} - -export function Switch({ checked, onChange, disabled, label, description }: Props) { - return ( - - ); -} diff --git a/client/ui/frontend/src/components/Tabs.tsx b/client/ui/frontend/src/components/Tabs.tsx deleted file mode 100644 index e82029c4c..000000000 --- a/client/ui/frontend/src/components/Tabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ReactNode, useState } from "react"; -import { cn } from "../lib/cn"; - -interface Tab { - value: string; - label: string; - content: ReactNode; -} - -interface Props { - tabs: Tab[]; - initial?: string; -} - -export function Tabs({ tabs, initial }: Props) { - const [active, setActive] = useState(initial ?? tabs[0]?.value); - return ( -
-
- {tabs.map((t) => ( - - ))} -
-
- {tabs.find((t) => t.value === active)?.content} -
-
- ); -} diff --git a/client/ui/frontend/src/components/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx similarity index 97% rename from client/ui/frontend/src/components/Button.tsx rename to client/ui/frontend/src/components/buttons/Button.tsx index f97d1962a..35f137ab5 100644 --- a/client/ui/frontend/src/components/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -3,15 +3,15 @@ import classNames from "classnames"; import { Check, Copy } from "lucide-react"; import { ButtonHTMLAttributes, forwardRef, useState } from "react"; -export type ButtonVariants = VariantProps; +type ButtonVariants = VariantProps; -export interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { +interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { disabled?: boolean; stopPropagation?: boolean; copy?: string; } -export const buttonVariants = cva( +const buttonVariants = cva( [ "relative", "text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm select-none", diff --git a/client/ui/frontend/src/components/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx similarity index 100% rename from client/ui/frontend/src/components/IconButton.tsx rename to client/ui/frontend/src/components/buttons/IconButton.tsx diff --git a/client/ui/frontend/src/components/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx similarity index 100% rename from client/ui/frontend/src/components/ConfirmDialog.tsx rename to client/ui/frontend/src/components/dialog/ConfirmDialog.tsx diff --git a/client/ui/frontend/src/components/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx similarity index 96% rename from client/ui/frontend/src/components/Dialog.tsx rename to client/ui/frontend/src/components/dialog/Dialog.tsx index 7f0680e05..744ac3c4e 100644 --- a/client/ui/frontend/src/components/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -5,11 +5,8 @@ import { X } from "lucide-react"; import { cn } from "@/lib/cn"; export const Root = DialogPrimitive.Root; -export const Trigger = DialogPrimitive.Trigger; -export const Close = DialogPrimitive.Close; -export const Portal = DialogPrimitive.Portal; -export const Overlay = forwardRef< +const Overlay = forwardRef< ElementRef, ComponentPropsWithoutRef >(function DialogOverlay({ className, ...props }, ref) { diff --git a/client/ui/frontend/src/components/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx similarity index 100% rename from client/ui/frontend/src/components/DialogActions.tsx rename to client/ui/frontend/src/components/dialog/DialogActions.tsx diff --git a/client/ui/frontend/src/components/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx similarity index 100% rename from client/ui/frontend/src/components/DialogDescription.tsx rename to client/ui/frontend/src/components/dialog/DialogDescription.tsx diff --git a/client/ui/frontend/src/components/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx similarity index 100% rename from client/ui/frontend/src/components/DialogHeading.tsx rename to client/ui/frontend/src/components/dialog/DialogHeading.tsx diff --git a/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx similarity index 94% rename from client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx rename to client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx index 339af6073..12e84df88 100644 --- a/client/ui/frontend/src/modules/daemon-status/DaemonUnavailableOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; import { AlertCircleIcon, BookText } from "lucide-react"; import { Browser } from "@wailsio/runtime"; -import { Button } from "@/components/Button"; -import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; const DOCS_URL = "https://docs.netbird.io/how-to/installation"; diff --git a/client/ui/frontend/src/components/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx similarity index 97% rename from client/ui/frontend/src/components/EmptyState.tsx rename to client/ui/frontend/src/components/empty-state/EmptyState.tsx index 217c8a228..ab9e05a26 100644 --- a/client/ui/frontend/src/components/EmptyState.tsx +++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import { ExternalLinkIcon, LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; -import { SquareIcon } from "./SquareIcon"; +import { SquareIcon } from "@/components/SquareIcon"; type Props = { icon: ComponentType; diff --git a/client/ui/frontend/src/components/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx similarity index 100% rename from client/ui/frontend/src/components/NoResults.tsx rename to client/ui/frontend/src/components/empty-state/NoResults.tsx diff --git a/client/ui/frontend/src/components/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx similarity index 100% rename from client/ui/frontend/src/components/NotConnectedState.tsx rename to client/ui/frontend/src/components/empty-state/NotConnectedState.tsx diff --git a/client/ui/frontend/src/components/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx similarity index 99% rename from client/ui/frontend/src/components/Input.tsx rename to client/ui/frontend/src/components/inputs/Input.tsx index f9a12e511..4dc834f2f 100644 --- a/client/ui/frontend/src/components/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -2,7 +2,7 @@ import { cva, VariantProps } from "class-variance-authority"; import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react"; import { cn } from "@/lib/cn"; -import { Label } from "@/components/Label"; +import { Label } from "@/components/typography/Label"; type InputVariants = VariantProps; diff --git a/client/ui/frontend/src/components/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx similarity index 100% rename from client/ui/frontend/src/components/SearchInput.tsx rename to client/ui/frontend/src/components/inputs/SearchInput.tsx diff --git a/client/ui/frontend/src/components/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx similarity index 91% rename from client/ui/frontend/src/components/FancyToggleSwitch.tsx rename to client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx index b6d22bc2e..9bdfaaff3 100644 --- a/client/ui/frontend/src/components/FancyToggleSwitch.tsx +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { HelpText } from "@/components/HelpText"; -import { Label } from "@/components/Label"; -import { ToggleSwitch } from "@/components/ToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch"; import { cn } from "@/lib/cn"; interface Props { diff --git a/client/ui/frontend/src/components/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx similarity index 94% rename from client/ui/frontend/src/components/SwitchItem.tsx rename to client/ui/frontend/src/components/switches/SwitchItem.tsx index f9d6fad52..86327d67e 100644 --- a/client/ui/frontend/src/components/SwitchItem.tsx +++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx @@ -2,7 +2,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group"; import { motion } from "framer-motion"; import { ReactNode } from "react"; import { cn } from "@/lib/cn"; -import { useSwitchItemGroup } from "@/components/SwitchItemGroup"; +import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup"; type Props = { value: string; diff --git a/client/ui/frontend/src/components/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx similarity index 100% rename from client/ui/frontend/src/components/SwitchItemGroup.tsx rename to client/ui/frontend/src/components/switches/SwitchItemGroup.tsx diff --git a/client/ui/frontend/src/components/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx similarity index 100% rename from client/ui/frontend/src/components/ToggleSwitch.tsx rename to client/ui/frontend/src/components/switches/ToggleSwitch.tsx diff --git a/client/ui/frontend/src/components/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx similarity index 100% rename from client/ui/frontend/src/components/HelpText.tsx rename to client/ui/frontend/src/components/typography/HelpText.tsx diff --git a/client/ui/frontend/src/components/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx similarity index 100% rename from client/ui/frontend/src/components/Label.tsx rename to client/ui/frontend/src/components/typography/Label.tsx diff --git a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx similarity index 69% rename from client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx rename to client/ui/frontend/src/contexts/ClientVersionContext.tsx index e9664ce79..af6b7f27c 100644 --- a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -9,6 +9,8 @@ import { type ReactNode, } from "react"; import { Dialogs, Events } from "@wailsio/runtime"; + + import { Update as UpdateSvc, WindowManager } from "@bindings/services"; import type { State as UpdateState } from "@bindings/updater/models.js"; import i18next from "@/lib/i18n"; @@ -34,18 +36,6 @@ type ClientVersionContextValue = { const EVENT_UPDATE_STATE = "netbird:update:state"; -// Dev tab in Settings emits this with { updateAvailable, enforced, version }. -// Lives only in-memory in the main window for the session — losing it when -// Settings closes is acceptable per the dev-toggle scope (no daemon write, -// no persistence). See SettingsDevelopment.tsx. -const EVENT_DEV_OVERRIDES = "netbird:dev:overrides"; - -type DevOverrides = { - updateAvailable: boolean; - enforced: boolean; - version: string; -}; - const emptyState: UpdateState = { available: false, version: "", @@ -66,7 +56,6 @@ export const useClientVersion = () => { export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { const [state, setState] = useState(emptyState); const [updating, setUpdating] = useState(false); - const [devOverride, setDevOverride] = useState(null); useEffect(() => { let cancelled = false; @@ -91,46 +80,23 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => }; }, []); - useEffect(() => { - const off = Events.On(EVENT_DEV_OVERRIDES, (ev: { data: DevOverrides }) => { - if (ev?.data) setDevOverride(ev.data); - }); - return () => { - off?.(); - }; - }, []); - - // Dev override only kicks in when it explicitly forces updateAvailable on. - // Otherwise daemon truth wins. - const effective = useMemo(() => { - if (devOverride && devOverride.updateAvailable) { - return { - available: true, - version: devOverride.version || "0.65.0", - enforced: devOverride.enforced, - installing: state.installing, - }; - } - return state; - }, [state, devOverride]); - // Force-install branch: daemon's progress_window:show flipped installing // to true while the UI was idle. Open the install window so the user // sees the progress UI without having to click anything. const prevInstallingRef = useRef(false); useEffect(() => { - if (effective.installing && !prevInstallingRef.current) { - WindowManager.OpenInstallProgress(effective.version || "").catch(console.error); + if (state.installing && !prevInstallingRef.current) { + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); } - prevInstallingRef.current = effective.installing; - }, [effective.installing, effective.version]); + prevInstallingRef.current = state.installing; + }, [state.installing, state.version]); // Enforced user-driven branch: kick Trigger() in the background, then // hand off to the install window. The window owns the polling loop and // the final Quit() — this provider just fires the trigger. const triggerUpdate = useCallback(() => { setUpdating(true); - WindowManager.OpenInstallProgress(effective.version || "").catch(console.error); + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); UpdateSvc.Trigger() .catch(async (e) => { // The daemon may already be down (force-install branch raced @@ -146,18 +112,18 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => }); }) .finally(() => setUpdating(false)); - }, [effective.version]); + }, [state.version]); const value = useMemo( () => ({ - updateAvailable: effective.available, - updateVersion: effective.version || null, - enforced: effective.enforced, - installing: effective.installing, + updateAvailable: state.available, + updateVersion: state.version || null, + enforced: state.enforced, + installing: state.installing, triggerUpdate, updating, }), - [effective, triggerUpdate, updating], + [state, triggerUpdate, updating], ); return ( diff --git a/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts b/client/ui/frontend/src/contexts/DebugBundleContext.tsx similarity index 87% rename from client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts rename to client/ui/frontend/src/contexts/DebugBundleContext.tsx index d601518ed..8233329ec 100644 --- a/client/ui/frontend/src/modules/debug-bundle/useDebugBundle.ts +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -1,4 +1,10 @@ -import { useRef, useState } from "react"; +import { + createContext, + useContext, + useRef, + useState, + type ReactNode, +} from "react"; import { Dialogs } from "@wailsio/runtime"; import { Connection as ConnectionSvc, @@ -7,7 +13,7 @@ import { import type { DebugBundleResult } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; import { formatErrorMessage } from "@/lib/errors.ts"; -import { useProfile } from "@/modules/profile/ProfileContext.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; const TRACE_LOG_FILE_COUNT = 5; @@ -44,7 +50,7 @@ const sleep = (ms: number, signal: AbortSignal) => const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError"; -export const useDebugBundle = () => { +const useDebugBundle = () => { const { activeProfile, username } = useProfile(); const [anonymize, setAnonymize] = useState(false); const [systemInfo, setSystemInfo] = useState(true); @@ -191,3 +197,26 @@ export const useDebugBundle = () => { openBundleDir, }; }; + +export type DebugBundleContextValue = ReturnType; + +const DebugBundleContext = createContext(null); + +export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { + const value = useDebugBundle(); + return ( + + {children} + + ); +}; + +export const useDebugBundleContext = () => { + const ctx = useContext(DebugBundleContext); + if (!ctx) { + throw new Error( + "useDebugBundleContext must be used inside DebugBundleProvider", + ); + } + return ctx; +}; diff --git a/client/ui/frontend/src/lib/navSection.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx similarity index 100% rename from client/ui/frontend/src/lib/navSection.tsx rename to client/ui/frontend/src/contexts/NavSectionContext.tsx diff --git a/client/ui/frontend/src/modules/networks/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx similarity index 99% rename from client/ui/frontend/src/modules/networks/NetworksContext.tsx rename to client/ui/frontend/src/contexts/NetworksContext.tsx index c60c6e0b4..e928723e5 100644 --- a/client/ui/frontend/src/modules/networks/NetworksContext.tsx +++ b/client/ui/frontend/src/contexts/NetworksContext.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { Networks as NetworksSvc } from "@bindings/services"; import type { Network } from "@bindings/services/models.js"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; +import { useStatus } from "@/contexts/StatusContext"; // A range is treated as an exit-node candidate when any of its CIDRs is a // default route (v4 or v6). The daemon may merge a v4+v6 pair into a single diff --git a/client/ui/frontend/src/modules/peers/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx similarity index 100% rename from client/ui/frontend/src/modules/peers/PeerDetailContext.tsx rename to client/ui/frontend/src/contexts/PeerDetailContext.tsx diff --git a/client/ui/frontend/src/modules/profile/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx similarity index 100% rename from client/ui/frontend/src/modules/profile/ProfileContext.tsx rename to client/ui/frontend/src/contexts/ProfileContext.tsx diff --git a/client/ui/frontend/src/modules/settings/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx similarity index 96% rename from client/ui/frontend/src/modules/settings/SettingsContext.tsx rename to client/ui/frontend/src/contexts/SettingsContext.tsx index 1ba74aad0..3df070e11 100644 --- a/client/ui/frontend/src/modules/settings/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -11,8 +11,8 @@ import { Dialogs } from "@wailsio/runtime"; import { Settings as SettingsSvc } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; -import { useProfile } from "@/modules/profile/ProfileContext.tsx"; -import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; import { formatErrorMessage as errorMessage } from "@/lib/errors.ts"; const SAVE_DEBOUNCE_MS = 400; @@ -150,7 +150,7 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { return (
{!config ? ( - + ) : ( { return (
@@ -14,12 +18,7 @@ export const AppLayout = () => { - - -
- - - + diff --git a/client/ui/frontend/src/layouts/MainRightSide.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx similarity index 94% rename from client/ui/frontend/src/layouts/MainRightSide.tsx rename to client/ui/frontend/src/layouts/AppRightPanel.tsx index a3e28e4dc..257fb1550 100644 --- a/client/ui/frontend/src/layouts/MainRightSide.tsx +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -16,7 +16,7 @@ const PANEL_TRANSITION = { ease: [0.32, 0.72, 0, 1] as [number, number, number, number], }; -export const MainRightSide = ({ children, overlay, overlayOpen = false }: Props) => { +export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => { return (
{ - return ( - - - - - - ); -}; - -const MainBody = () => { - const { viewMode } = useViewMode(); - const isAdvanced = viewMode === "advanced"; - const { section } = useNavSection(); - const { selected } = usePeerDetail(); - const { status } = useStatus(); - const isConnected = status?.status === "Connected"; - - return ( -
-
- -
- {isAdvanced && ( - } - overlayOpen={selected !== null} - > -
- -
- {section === "peers" && } - {section === "networks" && } - {section === "exitNode" && } -
-
- {!isConnected && ( -
- -
- )} -
- )} -
- ); -}; diff --git a/client/ui/frontend/src/layouts/SettingsLayout.tsx b/client/ui/frontend/src/layouts/SettingsLayout.tsx deleted file mode 100644 index d136df32a..000000000 --- a/client/ui/frontend/src/layouts/SettingsLayout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Outlet } from "react-router-dom"; -import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; -import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx"; -import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; -import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; - -// SettingsLayout wraps the Settings screen for use inside its own dedicated -// window. Same provider stack as AppLayout but without the main Header — the -// settings window has its own native title bar and doesn't show the profile -// selector / panel toggle / settings icon. -// -// The h-10 placeholder strip at the top accounts for the macOS -// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native -// title bar is invisible but the traffic-light buttons still float in the -// top-left corner. The height also mirrors the main window's Header so the -// MainRightSide panel ends up the same height in both windows. -export const SettingsLayout = () => { - return ( -
- - - - -
- - - - - -
- ); -}; diff --git a/client/ui/frontend/src/lib/color.ts b/client/ui/frontend/src/lib/color.ts deleted file mode 100644 index 4375bc7e6..000000000 --- a/client/ui/frontend/src/lib/color.ts +++ /dev/null @@ -1,17 +0,0 @@ -import chroma from "chroma-js"; - -export const generateColorFromString = (str?: string) => { - if (!str) return "#f68330"; - if (str.includes("System")) return "#808080"; - if (str.toLowerCase().startsWith("netbird")) return "#f68330"; - let hash = 0; - str.split("").forEach((char) => { - hash = char.charCodeAt(0) + ((hash << 5) - hash); - }); - let colour = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - colour += value.toString(16).padStart(2, "0"); - } - return chroma(colour).saturate(2).luminance(0.4).hex(); -}; diff --git a/client/ui/frontend/src/modules/peers/format.ts b/client/ui/frontend/src/lib/formatters.ts similarity index 100% rename from client/ui/frontend/src/modules/peers/format.ts rename to client/ui/frontend/src/lib/formatters.ts diff --git a/client/ui/frontend/src/modules/auto-update/InstallProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx similarity index 93% rename from client/ui/frontend/src/modules/auto-update/InstallProgressDialog.tsx rename to client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx index 9087b1ec7..3047e0a5a 100644 --- a/client/ui/frontend/src/modules/auto-update/InstallProgressDialog.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx @@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Loader2, XCircle } from "lucide-react"; import { Update as UpdateSvc, WindowManager } from "@bindings/services"; -import { Button } from "@/components/Button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { DialogActions } from "@/components/DialogActions"; -import { DialogDescription } from "@/components/DialogDescription"; -import { DialogHeading } from "@/components/DialogHeading"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; -import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const TIMEOUT_MS = 15 * 60 * 1000; const POLL_INTERVAL_MS = 2000; @@ -25,7 +25,7 @@ type Phase = | { kind: "canceled" } | { kind: "failed"; message: string }; -export default function InstallProgressDialog() { +export default function UpdateInProgressDialog() { const { t } = useTranslation(); const [params] = useSearchParams(); const version = params.get("version") ?? ""; diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx index 2b8205de9..24310d9eb 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -2,8 +2,8 @@ import { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import { DownloadIcon, NotepadText } from "lucide-react"; -import { Button } from "@/components/Button"; -import { useClientVersion } from "@/modules/auto-update/ClientVersionContext"; +import { Button } from "@/components/buttons/Button"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; import { cn } from "@/lib/cn"; const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; diff --git a/client/ui/frontend/src/modules/debug-bundle/DebugBundleContext.tsx b/client/ui/frontend/src/modules/debug-bundle/DebugBundleContext.tsx deleted file mode 100644 index d4e443fc3..000000000 --- a/client/ui/frontend/src/modules/debug-bundle/DebugBundleContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext, type ReactNode } from "react"; -import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts"; - -export type DebugBundleContextValue = ReturnType; - -export const DebugBundleContext = - createContext(null); - -export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { - const value = useDebugBundle(); - return ( - - {children} - - ); -}; diff --git a/client/ui/frontend/src/modules/debug-bundle/useDebugBundleContext.ts b/client/ui/frontend/src/modules/debug-bundle/useDebugBundleContext.ts deleted file mode 100644 index 43fd0ddac..000000000 --- a/client/ui/frontend/src/modules/debug-bundle/useDebugBundleContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx"; - -export const useDebugBundleContext = () => { - const ctx = useContext(DebugBundleContext); - if (!ctx) { - throw new Error( - "useDebugBundleContext must be used inside DebugBundleProvider", - ); - } - return ctx; -}; diff --git a/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx b/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx deleted file mode 100644 index bc67600eb..000000000 --- a/client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as RadioGroup from "@radix-ui/react-radio-group"; -import { useTranslation } from "react-i18next"; -import type { Network } from "@bindings/services/models.js"; -import { cn } from "@/lib/cn"; - -const NONE_VALUE = "__none__"; - -type Props = { - data: Network[]; - onToggle: (id: string, selected: boolean) => void; -}; - -export const ExitNodesList = ({ data, onToggle }: Props) => { - 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) => ( - - - {label} - - - - - -); diff --git a/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx similarity index 87% rename from client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx rename to client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index 172f9fd47..a4693a7d1 100644 --- a/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -4,19 +4,19 @@ import { useSearchParams } from "react-router-dom"; import { Dialogs, Events } from "@wailsio/runtime"; import { Loader2 } from "lucide-react"; import { Connection } from "@bindings/services"; -import { Button } from "@/components/Button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { DialogActions } from "@/components/DialogActions"; -import { DialogDescription } from "@/components/DialogDescription"; -import { DialogHeading } from "@/components/DialogHeading"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; -import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; import { formatErrorMessage } from "@/lib/errors"; const EVENT_CANCEL = "browser-login:cancel"; const WINDOW_WIDTH = 360; -export default function WaitingForBrowserDialog() { +export default function LoginWaitingForBrowserDialog() { const { t } = useTranslation(); const [params] = useSearchParams(); const uri = params.get("uri") ?? ""; diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx similarity index 98% rename from client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx rename to client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 87e134421..b1f4fd393 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -3,9 +3,9 @@ import { useTranslation } from "react-i18next"; import { Dialogs, Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; -import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; -import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; -import { useProfile } from "@/modules/profile/ProfileContext.tsx"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; import { formatErrorMessage } from "@/lib/errors.ts"; import { CopyToClipboard } from "@/components/CopyToClipboard"; @@ -114,7 +114,7 @@ async function startLogin(): Promise { } } -export const ConnectionStatusSwitch = () => { +export const MainConnectionStatusSwitch = () => { const { t } = useTranslation(); const { status, refresh } = useStatus(); const { activeProfile, username } = useProfile(); diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx similarity index 94% rename from client/ui/frontend/src/layouts/Header.tsx rename to client/ui/frontend/src/modules/main/MainHeader.tsx index 0fa253d32..e6727b90d 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -18,16 +18,16 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/DropdownMenu"; -import { IconButton } from "@/components/IconButton"; -import { ProfileDropdown } from "@/components/ProfileDropdown"; -import { useClientVersion } from "@/modules/auto-update/ClientVersionContext"; +import { IconButton } from "@/components/buttons/IconButton"; +import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; import { cn } from "@/lib/cn"; -import { formatShortcut, useKeyboardShortcut } from "@/lib/useKeyboardShortcut"; -import { useViewMode, type ViewMode } from "@/lib/viewMode"; +import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; +import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; -export const Header = () => { +export const MainHeader = () => { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); const { viewMode, setViewMode } = useViewMode(); diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx new file mode 100644 index 000000000..079165622 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -0,0 +1,89 @@ +import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx"; +import { MainHeader } from "@/modules/main/MainHeader.tsx"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { Navigation } from "@/modules/main/advanced/Navigation.tsx"; +import { cn } from "@/lib/cn"; +import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext"; +import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext"; +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"; + +export const MainPage = () => { + return ( + + + + + + + + + ); +}; + +const MainBody = () => { + const { viewMode } = useViewMode(); + const isAdvanced = viewMode === "advanced"; + + return ( +
+
+ +
+ {isAdvanced && ( + + + + )} +
+ ); +}; + +const AdvancedAppRightPanel = () => { + const { section } = useNavSection(); + const { selected } = usePeerDetail(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + + return ( + } + overlayOpen={selected !== null} + > +
+ +
+ {section === "peers" && } + {section === "networks" && } + {section === "exitNode" && } +
+
+ {!isConnected && ( +
+ +
+ )} +
+ ); +}; diff --git a/client/ui/frontend/src/layouts/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx similarity index 93% rename from client/ui/frontend/src/layouts/Navigation.tsx rename to client/ui/frontend/src/modules/main/advanced/Navigation.tsx index 6a73e0bc1..6455b576b 100644 --- a/client/ui/frontend/src/layouts/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -2,8 +2,8 @@ import { ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react"; import { cn } from "@/lib/cn"; -import { useNavSection, type NavSection } from "@/lib/navSection"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; +import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; +import { useStatus } from "@/contexts/StatusContext"; type TabEntry = { value: NavSection; @@ -80,4 +80,4 @@ const ExitNodeIcon = ({ size, ...props }: LucideProps) => ( /> ); -export type { NavSection } from "@/lib/navSection"; +export type { NavSection } from "@/contexts/NavSectionContext"; diff --git a/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx similarity index 61% rename from client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx rename to client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx index bcbe9e1ee..1c520ebf9 100644 --- a/client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx +++ b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx @@ -1,14 +1,17 @@ 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/SearchInput"; -import { EmptyState } from "@/components/EmptyState"; -import { NoResults } from "@/components/NoResults"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; -import { useNetworks } from "@/modules/networks/NetworksContext"; -import { ExitNodesList } from "./ExitNodesList"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { useStatus } from "@/contexts/StatusContext"; +import { useNetworks } from "@/contexts/NetworksContext"; + +const NONE_VALUE = "__none__"; export const ExitNodes = () => { const { t } = useTranslation(); @@ -104,3 +107,74 @@ export const ExitNodes = () => {
); }; + +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) => ( + + + {label} + + + + + +); diff --git a/client/ui/frontend/src/modules/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx similarity index 100% rename from client/ui/frontend/src/modules/networks/NetworkFilters.tsx rename to client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx diff --git a/client/ui/frontend/src/modules/networks/NetworksList.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx similarity index 52% rename from client/ui/frontend/src/modules/networks/NetworksList.tsx rename to client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index e49afd34d..4d3b0bba6 100644 --- a/client/ui/frontend/src/modules/networks/NetworksList.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -1,10 +1,17 @@ -import type { ComponentType } from "react"; -import * as Popover from "@radix-ui/react-popover"; -import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ComponentType } 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 { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; import type { Network } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { SearchInput } from "@/components/inputs/SearchInput"; +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 { NetworkFilter, NetworkFilters } from "./NetworkFilters"; // The daemon stringifies route.Network via netip.Prefix.String(). For // DNS-based routes the prefix is the zero value, which Go renders as @@ -44,26 +51,202 @@ const resourceIconFor = (type: ResourceType): ComponentType => { return NetworkIcon; }; -const ResourceIconBadge = ({ type }: { type: ResourceType }) => { - const Icon = resourceIconFor(type); +// Map every range string -> ids of CIDR routes that share it. Domain routes +// are skipped (they overlap on domain, not prefix). Single-entry buckets +// aren't overlaps. +const buildOverlapMap = ( + routes: { id: string; range: string; domains: string[] }[], +): Map => { + const byRange = new Map(); + for (const r of routes) { + if (r.domains.length > 0) continue; + const arr = byRange.get(r.range) ?? []; + arr.push(r.id); + byRange.set(r.range, arr); + } + const out = new Map(); + for (const [range, ids] of byRange) { + if (ids.length > 1) out.set(range, ids); + } + return out; +}; + +export const Networks = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]); + + const overlapById = useMemo(() => { + const map = new Map(); + for (const ids of overlapGroups.values()) { + for (const id of ids) map.set(id, ids); + } + return map; + }, [overlapGroups]); + + const counts = useMemo>( + () => ({ + all: networkRoutes.length, + active: networkRoutes.filter((r) => r.selected).length, + overlapping: overlapById.size, + }), + [networkRoutes, overlapById], + ); + + // Initial order: active-first, then by id. After that, positions are sticky + // — toggling a row doesn't move it, and newly discovered routes append at + // the end (sorted active-first / by-id among themselves). The ref carries + // the previous order across renders so the reconciliation is synchronous + // with networkRoutes updates (no useEffect lag → no visual hop). + const orderRef = useRef([]); + const ordered = useMemo(() => { + const byId = new Map(networkRoutes.map((r) => [r.id, r])); + const kept = orderRef.current.filter((id) => byId.has(id)); + const known = new Set(kept); + const fresh = networkRoutes + .filter((r) => !known.has(r.id)) + .sort((a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }) + .map((r) => r.id); + const next = [...kept, ...fresh]; + orderRef.current = next; + return next.map((id) => byId.get(id)!); + }, [networkRoutes]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((r) => { + 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(); + if (!haystack.includes(q)) return false; + } + return true; + }); + }, [ordered, search, filter, overlapById]); + + if (isConnected && networkRoutes.length === 0) { + return ( +
+ +
+ ); + } + + const selectedInView = filtered.filter((r) => r.selected).length; + const allSelected = filtered.length > 0 && selectedInView === filtered.length; + const bulkLabel = allSelected + ? t("networks.bulk.disableAll") + : t("networks.bulk.enableAll"); + + const onBulkClick = () => { + if (filtered.length === 0) return; + if (allSelected) { + void setNetworksSelected( + filtered.map((r) => r.id), + false, + ); + } else { + const ids = filtered.filter((r) => !r.selected).map((r) => r.id); + void setNetworksSelected(ids, true); + } + }; + return ( -
+
+
+ setSearch(e.target.value)} + /> +
+ +
+ + + {filtered.length === 0 ? ( + + ) : ( + + )} + + + + + + {filtered.length > 0 && ( +
+ + {t("networks.bulk.selectionCount", { + selected: selectedInView, + total: filtered.length, + })} + + +
)} - > -
); }; -type Props = { +type NetworksListProps = { data: Network[]; onToggle: (id: string, selected: boolean) => void; }; -export const NetworksList = ({ data, onToggle }: Props) => { +const NetworksList = ({ data, onToggle }: NetworksListProps) => { const { t } = useTranslation(); return ( @@ -113,6 +296,20 @@ export const NetworksList = ({ data, onToggle }: Props) => { ); }; +const ResourceIconBadge = ({ type }: { type: ResourceType }) => { + const Icon = resourceIconFor(type); + return ( +
+ +
+ ); +}; + const Subtitle = ({ network }: { network: Network }) => { if (isDnsRoute(network)) { const domain = network.domains[0]; @@ -135,12 +332,7 @@ const Subtitle = ({ network }: { network: Network }) => { return null; }; -type DomainSubtitleProps = { - domain: string; - ips: string[]; -}; - -const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => { +const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => { const first = ips[0]; const extra = ips.length - 1; @@ -235,7 +427,7 @@ type ToggleProps = { mixed?: boolean; }; -export const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => ( +const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => ( + + + {selected.fqdn || selected.ip} + +
+ + + + + + + + + + )} + + ); +}; + +const PeerDetails = ({ peer }: { peer: PeerStatus }) => { const { t } = useTranslation(); const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never"); const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH; diff --git a/client/ui/frontend/src/modules/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx similarity index 100% rename from client/ui/frontend/src/modules/peers/PeerFilters.tsx rename to client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx diff --git a/client/ui/frontend/src/modules/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx similarity index 51% rename from client/ui/frontend/src/modules/peers/Peers.tsx rename to client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 84c1c8583..33c9651dc 100644 --- a/client/ui/frontend/src/modules/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -1,17 +1,31 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as ScrollArea from "@radix-ui/react-scroll-area"; -import { LaptopIcon } from "lucide-react"; +import { ChevronRightIcon, LaptopIcon } from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; -import { SearchInput } from "@/components/SearchInput"; -import { EmptyState } from "@/components/EmptyState"; -import { NoResults } from "@/components/NoResults"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { latencyColor } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { PeerFilters, StatusFilter } from "./PeerFilters"; -import { PeersList } from "./PeersList"; const isOnline = (connStatus: string) => connStatus === "Connected"; +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + export const Peers = () => { const { t } = useTranslation(); const { status } = useStatus(); @@ -19,7 +33,7 @@ export const Peers = () => { const [statusFilter, setStatusFilter] = useState("all"); const searchRef = useRef(null); - // Peers is only mounted in advanced view (see layouts/Main.tsx), so a + // Peers is only mounted in advanced view (see pages/Main.tsx), so a // mount-time focus is equivalent to "focus when the user toggles into // advanced view". useEffect(() => { @@ -110,3 +124,71 @@ export const Peers = () => {
); }; + +const PeersList = ({ data }: { data: PeerStatus[] }) => { + const { setSelected } = usePeerDetail(); + + return ( +
    + {data.map((peer) => { + const isConnected = peer.connStatus === "Connected"; + return ( +
  • setSelected(peer)} + className={cn( + "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", + )} + > + +
    +
    + + + {peer.fqdn} + + +
    +
    + + + {peer.ip} + + +
    +
    + {isConnected && peer.latencyMs > 0 && ( + + {peer.latencyMs} ms + + )} + +
  • + ); + })} +
+ ); +}; diff --git a/client/ui/frontend/src/modules/networks/Networks.tsx b/client/ui/frontend/src/modules/networks/Networks.tsx deleted file mode 100644 index 54816baba..000000000 --- a/client/ui/frontend/src/modules/networks/Networks.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import * as ScrollArea from "@radix-ui/react-scroll-area"; -import { NetworkIcon } from "lucide-react"; -import { cn } from "@/lib/cn"; -import { SearchInput } from "@/components/SearchInput"; -import { EmptyState } from "@/components/EmptyState"; -import { NoResults } from "@/components/NoResults"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; -import { NetworkFilter, NetworkFilters } from "./NetworkFilters"; -import { NetworksList } from "./NetworksList"; -import { useNetworks } from "./NetworksContext"; - -// Map every range string -> ids of CIDR routes that share it. Domain routes -// are skipped (they overlap on domain, not prefix). Single-entry buckets -// aren't overlaps. -const buildOverlapMap = ( - routes: { id: string; range: string; domains: string[] }[], -): Map => { - const byRange = new Map(); - for (const r of routes) { - if (r.domains.length > 0) continue; - const arr = byRange.get(r.range) ?? []; - arr.push(r.id); - byRange.set(r.range, arr); - } - const out = new Map(); - for (const [range, ids] of byRange) { - if (ids.length > 1) out.set(range, ids); - } - return out; -}; - -export const Networks = () => { - const { t } = useTranslation(); - const { status } = useStatus(); - const isConnected = status?.status === "Connected"; - const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - const searchRef = useRef(null); - - useEffect(() => { - searchRef.current?.focus(); - }, []); - - const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]); - - const overlapById = useMemo(() => { - const map = new Map(); - for (const ids of overlapGroups.values()) { - for (const id of ids) map.set(id, ids); - } - return map; - }, [overlapGroups]); - - const counts = useMemo>( - () => ({ - all: networkRoutes.length, - active: networkRoutes.filter((r) => r.selected).length, - overlapping: overlapById.size, - }), - [networkRoutes, overlapById], - ); - - // Initial order: active-first, then by id. After that, positions are sticky - // — toggling a row doesn't move it, and newly discovered routes append at - // the end (sorted active-first / by-id among themselves). The ref carries - // the previous order across renders so the reconciliation is synchronous - // with networkRoutes updates (no useEffect lag → no visual hop). - const orderRef = useRef([]); - const ordered = useMemo(() => { - const byId = new Map(networkRoutes.map((r) => [r.id, r])); - const kept = orderRef.current.filter((id) => byId.has(id)); - const known = new Set(kept); - const fresh = networkRoutes - .filter((r) => !known.has(r.id)) - .sort((a, b) => { - if (a.selected !== b.selected) return a.selected ? -1 : 1; - return a.id.localeCompare(b.id); - }) - .map((r) => r.id); - const next = [...kept, ...fresh]; - orderRef.current = next; - return next.map((id) => byId.get(id)!); - }, [networkRoutes]); - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - return ordered.filter((r) => { - 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(); - if (!haystack.includes(q)) return false; - } - return true; - }); - }, [ordered, search, filter, overlapById]); - - if (isConnected && networkRoutes.length === 0) { - return ( -
- -
- ); - } - - const selectedInView = filtered.filter((r) => r.selected).length; - const allSelected = filtered.length > 0 && selectedInView === filtered.length; - const bulkLabel = allSelected - ? t("networks.bulk.disableAll") - : t("networks.bulk.enableAll"); - - const onBulkClick = () => { - if (filtered.length === 0) return; - if (allSelected) { - void setNetworksSelected( - filtered.map((r) => r.id), - false, - ); - } else { - const ids = filtered.filter((r) => !r.selected).map((r) => r.id); - void setNetworksSelected(ids, true); - } - }; - - return ( -
-
-
- setSearch(e.target.value)} - /> -
- -
- - - {filtered.length === 0 ? ( - - ) : ( - - )} - - - - - - {filtered.length > 0 && ( -
- - {t("networks.bulk.selectionCount", { - selected: selectedInView, - total: filtered.length, - })} - - -
- )} -
- ); -}; diff --git a/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx deleted file mode 100644 index 0745b1003..000000000 --- a/client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { AnimatePresence, motion, type Transition } from "framer-motion"; -import * as ScrollArea from "@radix-ui/react-scroll-area"; -import { ArrowLeftIcon } from "lucide-react"; -import { cn } from "@/lib/cn"; -import { useStatus } from "@/modules/daemon-status/StatusContext"; -import { PeerDetails } from "./PeerDetails"; -import { usePeerDetail } from "./PeerDetailContext"; - -const DEFAULT_TRANSITION: Transition = { - duration: 0.32, - ease: [0.32, 0.72, 0, 1], -}; - -const dotClass = (connStatus: string): string => { - switch (connStatus) { - case "Connected": - return "bg-green-400"; - case "Connecting": - return "bg-yellow-300 animate-pulse-slow"; - default: - return "bg-nb-gray-500"; - } -}; - -type Props = { - transition?: Transition; -}; - -export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { - const { t } = useTranslation(); - const { selected, setSelected } = usePeerDetail(); - const { status } = useStatus(); - - // Keep `selected` in sync with the live peer list so the panel reflects - // status / latency / byte updates without re-opening. If the peer - // disappears, close the panel. - useEffect(() => { - if (!selected) return; - const peers = status?.peers ?? []; - const fresh = peers.find((p) => p.pubKey === selected.pubKey); - if (!fresh) { - setSelected(null); - return; - } - if (fresh !== selected) setSelected(fresh); - }, [status, selected, setSelected]); - - // Esc closes the panel. - useEffect(() => { - if (!selected) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setSelected(null); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [selected, setSelected]); - - return ( - - {selected && ( - -
- - - - {selected.fqdn || selected.ip} - -
- - - - - - - - -
- )} -
- ); -}; diff --git a/client/ui/frontend/src/modules/peers/PeersList.tsx b/client/ui/frontend/src/modules/peers/PeersList.tsx deleted file mode 100644 index fa8ef8170..000000000 --- a/client/ui/frontend/src/modules/peers/PeersList.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ChevronRightIcon } from "lucide-react"; -import type { PeerStatus } from "@bindings/services/models.js"; -import { cn } from "@/lib/cn"; -import { CopyToClipboard } from "@/components/CopyToClipboard"; -import { latencyColor } from "./format"; -import { usePeerDetail } from "./PeerDetailContext"; - -const dotClass = (connStatus: string): string => { - switch (connStatus) { - case "Connected": - return "bg-green-400"; - case "Connecting": - return "bg-yellow-300 animate-pulse-slow"; - default: - return "bg-nb-gray-500"; - } -}; - -export const PeersList = ({ data }: { data: PeerStatus[] }) => { - const { setSelected } = usePeerDetail(); - - return ( -
    - {data.map((peer) => { - const isConnected = peer.connStatus === "Connected"; - return ( -
  • setSelected(peer)} - className={cn( - "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", - )} - > - -
    -
    - - - {peer.fqdn} - - -
    -
    - - - {peer.ip} - - -
    -
    - {isConnected && peer.latencyMs > 0 && ( - - {peer.latencyMs} ms - - )} - -
  • - ); - })} -
- ); -}; diff --git a/client/ui/frontend/src/modules/peers/peers.md b/client/ui/frontend/src/modules/peers/peers.md deleted file mode 100644 index ce8c34765..000000000 --- a/client/ui/frontend/src/modules/peers/peers.md +++ /dev/null @@ -1,48 +0,0 @@ -# Peers — info missing in PeersList.tsx - -`PeersList.tsx` currently shows only: `connStatus` (dot), `fqdn`, `ip`. - -`screens/Peers.tsx` additionally surfaces the following fields from `PeerStatus`: - -## Row chrome (collapsed) -- `peer.relayed` — Network (relayed, yellow) vs Zap (P2P, green) icon, gated on `connStatus === "Connected"`. -- `peer.rosenpassEnabled` — ShieldCheck icon when true. -- `peer.latencyMs` — `"{n} ms"` on the right when Connected and > 0. - -## Top-level controls -- Filter input — matches against `fqdn`, `ip`, and each entry in `networks`. -- Peer count — `status.peers.length` next to the title. -- Expand/collapse per row (chevron). - -## Expanded details panel -- `peer.pubKey` — Public key (mono). -- `peer.lastHandshakeUnix` — Last handshake (relative time). -- `peer.connStatusUpdateUnix` — Status since (relative time). -- `peer.bytesRx` / `peer.bytesTx` — formatted B/KB/MB/GB. -- `peer.localIceCandidateType` + `peer.localIceCandidateEndpoint` — Local candidate. -- `peer.remoteIceCandidateType` + `peer.remoteIceCandidateEndpoint` — Remote candidate. -- `peer.relayAddress` — shown only when `peer.relayed`. -- `peer.networks` — joined list, shown when non-empty. - -## `PeerStatus` interface (from `@bindings/services/models.js`) -```ts -interface PeerStatus { - ip: string; - pubKey: string; - connStatus: string; // "Connected" | "Connecting" | "Idle" | ... - connStatusUpdateUnix: number; - relayed: boolean; - localIceCandidateType: string; - remoteIceCandidateType: string; - localIceCandidateEndpoint: string; - remoteIceCandidateEndpoint: string; - fqdn: string; - bytesRx: number; - bytesTx: number; - latencyMs: number; - relayAddress: string; - lastHandshakeUnix: number; - rosenpassEnabled: boolean; - networks: string[]; -} -``` diff --git a/client/ui/frontend/src/components/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx similarity index 100% rename from client/ui/frontend/src/components/ProfileAvatar.tsx rename to client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx diff --git a/client/ui/frontend/src/components/NewProfileModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx similarity index 91% rename from client/ui/frontend/src/components/NewProfileModal.tsx rename to client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index c6d97838d..b03577a34 100644 --- a/client/ui/frontend/src/components/NewProfileModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -1,9 +1,9 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { PlusCircle } from "lucide-react"; -import * as Dialog from "@/components/Dialog"; -import { Input } from "@/components/Input"; -import { Button } from "@/components/Button"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Input } from "@/components/inputs/Input"; +import { Button } from "@/components/buttons/Button"; type Props = { open: boolean; @@ -11,7 +11,7 @@ type Props = { onCreate: (name: string) => void; }; -export const NewProfileModal = ({ open, onOpenChange, onCreate }: Props) => { +export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => { const { t } = useTranslation(); const [name, setName] = useState(""); const [error, setError] = useState(null); diff --git a/client/ui/frontend/src/components/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx similarity index 97% rename from client/ui/frontend/src/components/ProfileDropdown.tsx rename to client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index be6daea26..4c1134cc2 100644 --- a/client/ui/frontend/src/components/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -5,11 +5,11 @@ import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Command } from "cmdk"; import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react"; -import { pickProfileIcon } from "@/components/ProfileAvatar"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; import type { Profile } from "@bindings/services/models.js"; -import { NewProfileModal } from "@/components/NewProfileModal"; +import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal"; import { Tooltip } from "@/components/Tooltip"; -import { useProfile } from "@/modules/profile/ProfileContext"; +import { useProfile } from "@/contexts/ProfileContext"; import { cn } from "@/lib/cn"; import { formatErrorMessage } from "@/lib/errors"; @@ -165,7 +165,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { - - + ); } diff --git a/client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx similarity index 93% rename from client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx rename to client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx index 9e3829ebf..1d07b0988 100644 --- a/client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx @@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Dialogs } from "@wailsio/runtime"; import { ClockIcon } from "lucide-react"; -import { Button } from "@/components/Button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { DialogActions } from "@/components/DialogActions"; -import { DialogDescription } from "@/components/DialogDescription"; -import { DialogHeading } from "@/components/DialogHeading"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { Connection, @@ -15,7 +15,7 @@ import { Session, WindowManager, } from "@bindings/services"; -import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; import { formatErrorMessage } from "@/lib/errors.ts"; const DEFAULT_SECONDS = 360; diff --git a/client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx similarity index 81% rename from client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx rename to client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx index 78d477dd1..2a37590a8 100644 --- a/client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpiredDialog.tsx @@ -2,14 +2,14 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Events } from "@wailsio/runtime"; import { AlertCircleIcon } from "lucide-react"; -import { Button } from "@/components/Button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { DialogActions } from "@/components/DialogActions"; -import { DialogDescription } from "@/components/DialogDescription"; -import { DialogHeading } from "@/components/DialogHeading"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { WindowManager } from "@bindings/services"; -import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const EVENT_TRIGGER_LOGIN = "trigger-login"; const WINDOW_WIDTH = 360; diff --git a/client/ui/frontend/src/modules/settings/Settings.tsx b/client/ui/frontend/src/modules/settings/Settings.tsx deleted file mode 100644 index a3fc4d83c..000000000 --- a/client/ui/frontend/src/modules/settings/Settings.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useEffect, useState } from "react"; -import { useLocation, useSearchParams } from "react-router-dom"; -import * as ScrollArea from "@radix-ui/react-scroll-area"; -import { cn } from "@/lib/cn"; -import { MainRightSide } from "@/layouts/MainRightSide.tsx"; -import { VerticalTabs } from "@/components/VerticalTabs.tsx"; -import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx"; -import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx"; -import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; -import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; -import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; -import { SettingsProfiles } from "@/modules/settings/SettingsProfiles.tsx"; -import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; -import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; -import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; -import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; -import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx"; - -// The settings window opens at General by default. Navigation state (e.g. the -// update-available header trigger jumps to About) or a `?tab=` query param -// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from -// the profile dropdown) override the default. No persistence across opens — -// a user who wants to revisit a deep tab gets there in two clicks. -export const Settings = () => { - const location = useLocation(); - const [searchParams] = useSearchParams(); - const queryTab = searchParams.get("tab"); - const navState = location.state as { tab?: string } | null; - const [active, setActive] = useState( - () => navState?.tab ?? queryTab ?? "general", - ); - - useEffect(() => { - if (navState?.tab) setActive(navState.tab); - }, [navState?.tab, location.key]); - - return ( - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - {import.meta.env.DEV && ( - - - - )} - -
-
- - - -
-
-
- ); -}; diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 0ea94e578..9f8624b74 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -4,7 +4,7 @@ import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "luci import type { LucideIcon } from "lucide-react"; import netbirdFull from "@/assets/logos/netbird-full.svg"; import pkg from "../../../package.json"; -import { useStatus } from "@/modules/daemon-status/StatusContext.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index c0a1d55de..e28489308 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { System } from "@wailsio/runtime"; -import Button from "@/components/Button"; -import { HelpText } from "@/components/HelpText"; -import { Input } from "@/components/Input"; -import { Label } from "@/components/Label"; +import Button from "@/components/buttons/Button"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/modules/settings/SettingsContext.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; // macOS: the Darwin utun control socket parses the digits after "utun" as the // unit number, so the daemon (and the CLI's parseInterfaceName in diff --git a/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx b/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx deleted file mode 100644 index 929128923..000000000 --- a/client/ui/frontend/src/modules/settings/SettingsDevelopment.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useState } from "react"; -import { Events } from "@wailsio/runtime"; -import { Button } from "@/components/Button"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; -import { WindowManager } from "@bindings/services"; -import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; - -// Cross-window dev override: ClientVersionContext in the main window -// listens for this and replaces daemon-reported update state with the -// toggle values. Resets when the Settings window closes (no persistence -// by design). -const EVENT_DEV_OVERRIDES = "netbird:dev:overrides"; -const PREVIEW_VERSION = "0.65.0"; - -export function SettingsDevelopment() { - const [updateAvailable, setUpdateAvailable] = useState(false); - const [enforced, setEnforced] = useState(false); - - useEffect(() => { - void Events.Emit(EVENT_DEV_OVERRIDES, { - updateAvailable, - enforced, - version: PREVIEW_VERSION, - }); - }, [updateAvailable, enforced]); - - return ( - <> - - - -
- -
-
- - -
- - -
-
- - ); -} diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx index d3710c3a4..c493586b0 100644 --- a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -1,15 +1,15 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Button } from "@/components/Button"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; -import { HelpText } from "@/components/HelpText"; -import { Input } from "@/components/Input"; -import { Label } from "@/components/Label"; +import { Button } from "@/components/buttons/Button"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/modules/settings/SettingsContext.tsx"; -import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx"; -import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts"; -import { LanguagePicker } from "@/modules/settings/LanguagePicker.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"; +import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; +import { LanguagePicker } from "@/components/LanguagePicker.tsx"; export function SettingsGeneral() { const { t } = useTranslation(); diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx similarity index 85% rename from client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx rename to client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index f3b558dfd..8f04c165e 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next"; import { Tooltip } from "@/components/Tooltip.tsx"; import { VerticalTabs } from "@/components/VerticalTabs.tsx"; import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx"; -import { useClientVersion } from "@/modules/auto-update/ClientVersionContext.tsx"; +import { useClientVersion } from "@/contexts/ClientVersionContext.tsx"; import { BoltIcon, - HammerIcon, InfoIcon, LifeBuoyIcon, NetworkIcon, @@ -15,7 +14,7 @@ import { UserCircleIcon, } from "lucide-react"; -export const SettingsNavigationTriggers = () => { +export const SettingsNavigation = () => { const { t } = useTranslation(); const { updateAvailable } = useClientVersion(); @@ -69,13 +68,6 @@ export const SettingsNavigationTriggers = () => { title={t("settings.tabs.about")} adornment={aboutAdornment} /> - {import.meta.env.DEV && ( - - )}
); diff --git a/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx index 81581aa1c..32e2fcbd0 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/modules/settings/SettingsContext.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; export function SettingsNetwork() { const { t } = useTranslation(); diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx new file mode 100644 index 000000000..032450295 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx"; +import { SettingsProvider } from "@/contexts/SettingsContext.tsx"; +import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; +import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; +import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; +import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx"; +import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; +import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; +import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; +import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; + +// The settings window opens at General by default. Navigation state (e.g. the +// update-available header trigger jumps to About) or a `?tab=` query param +// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from +// the profile dropdown) override the default. No persistence across opens — +// a user who wants to revisit a deep tab gets there in two clicks. +// +// The `h-12` draggable strip at the top accounts for the macOS +// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light +// buttons float over invisible title bar) and mirrors the main window's +// Header height so AppRightPanel ends up the same height in both windows. +export const SettingsPage = () => { + const location = useLocation(); + const [searchParams] = useSearchParams(); + const queryTab = searchParams.get("tab"); + const navState = location.state as { tab?: string } | null; + const [active, setActive] = useState( + () => navState?.tab ?? queryTab ?? "general", + ); + + useEffect(() => { + if (navState?.tab) setActive(navState.tab); + }, [navState?.tab, location.key]); + + return ( + <> +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index dc7912756..acb4565cd 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -1,11 +1,11 @@ import { useTranslation } from "react-i18next"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; -import { HelpText } from "@/components/HelpText"; -import { Input } from "@/components/Input"; -import { Label } from "@/components/Label"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; import { cn } from "@/lib/cn"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/modules/settings/SettingsContext.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; import { type ChangeEvent, useEffect, useState } from "react"; export function SettingsSSH() { diff --git a/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx index 3e29d0f07..1151936ef 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/modules/settings/SettingsContext.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; export function SettingsSecurity() { const { t } = useTranslation(); diff --git a/client/ui/frontend/src/modules/skeletons/SkeletonSettings.tsx b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx similarity index 95% rename from client/ui/frontend/src/modules/skeletons/SkeletonSettings.tsx rename to client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx index e01207ae7..4eb646ee8 100644 --- a/client/ui/frontend/src/modules/skeletons/SkeletonSettings.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx @@ -1,6 +1,6 @@ import Skeleton from "react-loading-skeleton"; -export const SkeletonSettings = () => { +export const SettingsSkeleton = () => { return (
diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx index b321145de..f6cbe0b52 100644 --- a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -3,18 +3,18 @@ import { Trans, useTranslation } from "react-i18next"; import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react"; import { Debug as DebugSvc } from "@bindings/services"; import type { DebugBundleResult } from "@bindings/services/models.js"; -import { Button } from "@/components/Button"; -import { DialogActions } from "@/components/DialogActions"; -import { DialogDescription } from "@/components/DialogDescription"; -import { DialogHeading } from "@/components/DialogHeading"; -import FancyToggleSwitch from "@/components/FancyToggleSwitch"; -import HelpText from "@/components/HelpText.tsx"; -import { Input } from "@/components/Input"; -import { Label } from "@/components/Label"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import HelpText from "@/components/typography/HelpText.tsx"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; import { SquareIcon } from "@/components/SquareIcon"; import { cn } from "@/lib/cn"; -import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts"; -import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts"; +import type { DebugStage } from "@/contexts/DebugBundleContext"; +import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; export function SettingsTroubleshooting() { diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 746c1c094..0e787bdc6 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -106,9 +106,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { s.mu.Lock() defer s.mu.Unlock() if s.browserLogin == nil { - startURL := "/#/browser-login" + startURL := "/#/dialog/browser-login" if uri != "" { - startURL = "/#/browser-login?uri=" + url.QueryEscape(uri) + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") // Prefer the screen the main window is on so the sign-in popup @@ -169,7 +169,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { return } if uri != "" { - s.browserLogin.SetURL("/#/browser-login?uri=" + url.QueryEscape(uri)) + s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } s.browserLogin.Show() s.browserLogin.Focus() @@ -261,7 +261,7 @@ func (s *WindowManager) OpenSessionExpired() { MaximiseButtonState: application.ButtonHidden, CloseButtonState: application.ButtonEnabled, BackgroundColour: application.NewRGB(24, 26, 29), - URL: "/#/session-expired", + URL: "/#/dialog/session-expired", Mac: application.MacWindow{ InvisibleTitleBarHeight: 38, Backdrop: application.MacBackdropTranslucent, @@ -302,7 +302,7 @@ func (s *WindowManager) CloseSessionExpired() { func (s *WindowManager) OpenSessionAboutToExpire(seconds int) { s.mu.Lock() defer s.mu.Unlock() - startURL := "/#/session-about-to-expire?seconds=" + strconv.Itoa(seconds) + startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds) if s.sessionAboutToExpire == nil { s.sessionAboutToExpire = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "session-about-to-expire", @@ -364,9 +364,9 @@ func (s *WindowManager) CloseSessionAboutToExpire() { func (s *WindowManager) OpenInstallProgress(version string) { s.mu.Lock() defer s.mu.Unlock() - startURL := "/#/install-progress" + startURL := "/#/dialog/install-progress" if version != "" { - startURL = "/#/install-progress?version=" + url.QueryEscape(version) + startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) } if s.installProgress == nil { s.hideOtherWindowsLocked("install-progress")