diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index f0e862820..1502a57b5 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import QuickActions from "@/screens/QuickActions.tsx"; import LoginUrl from "@/pages/LoginUrl.tsx"; +import SessionExpired from "@/pages/SessionExpired.tsx"; import Update from "@/screens/Update.tsx"; import { AppLayout } from "@/layouts/AppLayout.tsx"; import { Main } from "@/layouts/Main.tsx"; @@ -22,6 +23,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> } /> } /> + } /> }> } /> } /> diff --git a/client/ui/frontend/src/assets/screens/advanced.png b/client/ui/frontend/src/assets/screens/advanced.png new file mode 100644 index 000000000..834ab6f89 Binary files /dev/null and b/client/ui/frontend/src/assets/screens/advanced.png differ diff --git a/client/ui/frontend/src/assets/screens/simple.png b/client/ui/frontend/src/assets/screens/simple.png new file mode 100644 index 000000000..0a0ede5d1 Binary files /dev/null and b/client/ui/frontend/src/assets/screens/simple.png differ diff --git a/client/ui/frontend/src/components/Avatar.tsx b/client/ui/frontend/src/components/Avatar.tsx new file mode 100644 index 000000000..0bc04293d --- /dev/null +++ b/client/ui/frontend/src/components/Avatar.tsx @@ -0,0 +1,34 @@ +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 new file mode 100644 index 000000000..b3e5acb42 --- /dev/null +++ b/client/ui/frontend/src/components/BottomSheet.tsx @@ -0,0 +1,60 @@ +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/Button.tsx b/client/ui/frontend/src/components/Button.tsx index 424242994..ad4933667 100644 --- a/client/ui/frontend/src/components/Button.tsx +++ b/client/ui/frontend/src/components/Button.tsx @@ -91,7 +91,7 @@ export const buttonVariants = cva( ], }, size: { - xs: "text-xs py-2 px-3.5", + xs: "text-xs py-2.5 px-3.5", xs2: "text-[0.78rem] py-2 px-4", sm: "text-sm py-[9px] px-4", md: "text-md py-[9px] px-4", diff --git a/client/ui/frontend/src/components/CardSelect.tsx b/client/ui/frontend/src/components/CardSelect.tsx new file mode 100644 index 000000000..31a2bed38 --- /dev/null +++ b/client/ui/frontend/src/components/CardSelect.tsx @@ -0,0 +1,76 @@ +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/ToggleSwitch.tsx b/client/ui/frontend/src/components/ToggleSwitch.tsx index 3bc2d0436..1b2421437 100644 --- a/client/ui/frontend/src/components/ToggleSwitch.tsx +++ b/client/ui/frontend/src/components/ToggleSwitch.tsx @@ -12,6 +12,7 @@ const switchVariants = cva("", { size: { default: "h-[24px] w-[44px]", small: "h-[18px] w-[36px]", + large: "h-[36px] w-[66px]", }, variant: { default: [ @@ -36,6 +37,7 @@ const switchVariants = cva("", { "thumb-size": { default: "h-5 w-5 data-[state=checked]:translate-x-5", small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]", + large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[30px]", }, }, }); diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css index 905b7cdb1..ce7bee58c 100644 --- a/client/ui/frontend/src/globals.css +++ b/client/ui/frontend/src/globals.css @@ -17,7 +17,7 @@ body, } body { - @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; + @apply bg-nb-gray/90 font-sans text-nb-gray-200 antialiased; } .wails-draggable { diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index 95a504523..4cc234942 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -1,5 +1,6 @@ import { Outlet } from "react-router-dom"; import { Header } from "@/layouts/Header.tsx"; +import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx"; import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; @@ -7,14 +8,16 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; export const AppLayout = () => { return (
- - - -
- - - - + + + + +
+ + + + +
); }; diff --git a/client/ui/frontend/src/layouts/ConnectionStatus.tsx b/client/ui/frontend/src/layouts/ConnectionStatus.tsx index b22d8c1b8..395578ec3 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatus.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatus.tsx @@ -1,25 +1,117 @@ -import { - ConnectionState, - NetBirdConnectToggle, -} from "@/components/NetBirdConnectToggle.tsx"; +import { useEffect, useRef, useState } from "react"; +import { ConnectionState, NetBirdConnectToggle } from "@/components/NetBirdConnectToggle.tsx"; +import Button from "@/components/Button.tsx"; +import { cn } from "@/lib/cn.ts"; + +const CONNECT_DURATION_MS = 1500; +const DISCONNECT_DURATION_MS = 800; + +const STATUS_LABEL: Record = { + [ConnectionState.Disconnected]: "Disconnected", + [ConnectionState.Connecting]: "Connecting...", + [ConnectionState.Connected]: "Connected", + [ConnectionState.Disconnecting]: "Disconnecting...", +}; export const ConnectionStatus = () => { + const [state, setState] = useState(ConnectionState.Disconnected); + const timerRef = useRef | null>(null); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [], + ); + + const transition = (next: ConnectionState, after: ConnectionState, delay: number) => { + if (timerRef.current) clearTimeout(timerRef.current); + setState(next); + timerRef.current = setTimeout(() => { + setState(after); + timerRef.current = null; + }, delay); + }; + + const connect = () => + transition(ConnectionState.Connecting, ConnectionState.Connected, CONNECT_DURATION_MS); + const disconnect = () => + transition( + ConnectionState.Disconnecting, + ConnectionState.Disconnected, + DISCONNECT_DURATION_MS, + ); + + const handleToggleClick = () => { + if (state === ConnectionState.Disconnected) connect(); + else if (state === ConnectionState.Connected) disconnect(); + }; + + const handleButtonClick = () => { + if (state === ConnectionState.Disconnected) { + connect(); + return; + } + if (state === ConnectionState.Connected) { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setState(ConnectionState.Disconnected); + } + }; + + const isTransitioning = + state === ConnectionState.Connecting || state === ConnectionState.Disconnecting; + const isConnectedSide = + state === ConnectionState.Connected || state === ConnectionState.Disconnecting; + + const buttonLabel = isConnectedSide ? "Disconnect" : "Connect"; + const buttonVariant = isConnectedSide ? "secondary" : "primary"; + return ( -
- -

- Connected -

-

- peer-hostname.netbird.cloud -

-

- 192.168.0.1 -

+
+
+
+

+ peer-hostname.netbird.cloud +

+

+ 192.168.0.1 +

+
+ +
+

+ {STATUS_LABEL[state]} +

+
+ +
+
+
); }; diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx new file mode 100644 index 000000000..09dd5f5f1 --- /dev/null +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import { ConnectionState } from "@/components/NetBirdConnectToggle.tsx"; +import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; +import { cn } from "@/lib/cn.ts"; +import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; + +const CONNECT_DURATION_MS = 1500; +const DISCONNECT_DURATION_MS = 800; + +const STATUS_LABEL: Record = { + [ConnectionState.Disconnected]: "Disconnected", + [ConnectionState.Connecting]: "Connecting...", + [ConnectionState.Connected]: "Connected", + [ConnectionState.Disconnecting]: "Disconnecting...", +}; + +export const ConnectionStatusSwitch = () => { + const [state, setState] = useState(ConnectionState.Disconnected); + const timerRef = useRef | null>(null); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [], + ); + + const transition = (next: ConnectionState, after: ConnectionState, delay: number) => { + if (timerRef.current) clearTimeout(timerRef.current); + setState(next); + timerRef.current = setTimeout(() => { + setState(after); + timerRef.current = null; + }, delay); + }; + + const connect = () => + transition(ConnectionState.Connecting, ConnectionState.Connected, CONNECT_DURATION_MS); + const disconnect = () => + transition( + ConnectionState.Disconnecting, + ConnectionState.Disconnected, + DISCONNECT_DURATION_MS, + ); + + const handleSwitch = (next: boolean) => { + if (next) { + if (state === ConnectionState.Disconnected) connect(); + } else if (state === ConnectionState.Connected) { + disconnect(); + } + }; + + const isTransitioning = + state === ConnectionState.Connecting || state === ConnectionState.Disconnecting; + const isOn = state === ConnectionState.Connected || state === ConnectionState.Connecting; + + return ( +
+ {"NetBird"} + + + +
+

+ {STATUS_LABEL[state]} +

+

+ peer-hostname.netbird.cloud +

+

+ 192.168.0.1 +

+
+
+ ); +}; diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index 197cc82dc..ba33df867 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -1,33 +1,76 @@ +import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { SettingsIcon } from "lucide-react"; +import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react"; +import { Window } from "@wailsio/runtime"; import { ProfileSelector } from "@/components/ProfileSelector.tsx"; import { IconButton } from "@/components/IconButton.tsx"; -import { UpdateHeaderTrigger } from "@/modules/auto-update/UpdateHeaderTrigger.tsx"; +import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; import { cn } from "@/lib/cn"; +const WINDOW_SMALL_WIDTH = 380; +const WINDOW_BIG_WIDTH = 925; +const WINDOW_HEIGHT = 615; +const EXPANDED_THRESHOLD = 500; + export const Header = () => { const navigate = useNavigate(); const location = useLocation(); const isSettingsPage = location.pathname.startsWith("/settings"); + const { showProfileSelector, showSettingsButton, expanded, setField } = useAppearance(); + const showSettings = showSettingsButton || isSettingsPage; + const didInitialResize = useRef(false); + + useEffect(() => { + if (didInitialResize.current) return; + didInitialResize.current = true; + const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; + void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const onResize = () => { + const isWide = window.innerWidth >= EXPANDED_THRESHOLD; + if (isWide !== expanded) setField("expanded", isWide); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [expanded, setField]); + + const togglePanel = () => { + const next = !expanded; + setField("expanded", next); + const w = next ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; + void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); + }; return (
-
- -
- + {showProfileSelector && ( +
+ +
+ )} + navigate(isSettingsPage ? "/" : "/settings")} - className={cn( - isSettingsPage && - "bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200", - )} + icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon} + onClick={togglePanel} /> + {showSettings && ( + navigate(isSettingsPage ? "/" : "/settings")} + className={cn( + isSettingsPage && + "bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200", + )} + /> + )}
); }; diff --git a/client/ui/frontend/src/layouts/Main.tsx b/client/ui/frontend/src/layouts/Main.tsx index b80168154..861005edb 100644 --- a/client/ui/frontend/src/layouts/Main.tsx +++ b/client/ui/frontend/src/layouts/Main.tsx @@ -1,22 +1,33 @@ import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx"; +import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx"; import { MainRightSide } from "@/layouts/MainRightSide.tsx"; import { Navigation } from "@/layouts/Navigation.tsx"; import { Peers } from "@/modules/peers/Peers.tsx"; +import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; +import { cn } from "@/lib/cn"; export const Main = () => { + const { connectionLayout, expanded } = useAppearance(); return (
- + {connectionLayout === "switch" ? ( + + ) : ( + + )}
- - - + {expanded && ( + + + + )}
); }; diff --git a/client/ui/frontend/src/layouts/Navigation.tsx b/client/ui/frontend/src/layouts/Navigation.tsx index ad971f090..646b4b7e1 100644 --- a/client/ui/frontend/src/layouts/Navigation.tsx +++ b/client/ui/frontend/src/layouts/Navigation.tsx @@ -1,6 +1,7 @@ import { CardNavItem } from "@/components/CardNavItem.tsx"; import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react"; import deFlag from "@/assets/flags/1x1/de.svg"; +import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; type Props = { peersActive?: boolean; @@ -8,32 +9,42 @@ type Props = { }; export const Navigation = ({ peersActive = false, onPeersClick }: Props) => { + const { showPeersNav, showResourcesNav, showExitNodeNav } = useAppearance(); + return ( ); }; diff --git a/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx b/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx new file mode 100644 index 000000000..ae72839d9 --- /dev/null +++ b/client/ui/frontend/src/modules/appearance/AppearanceContext.tsx @@ -0,0 +1,94 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +export type AppearanceView = "default" | "advanced"; +export type ConnectionLayout = "default" | "switch"; + +export type AppearanceState = { + view: AppearanceView; + connectionLayout: ConnectionLayout; + expanded: boolean; + showPeersNav: boolean; + showResourcesNav: boolean; + showExitNodeNav: boolean; + showProfileSelector: boolean; + showSettingsButton: boolean; +}; + +const STORAGE_KEY = "netbird:appearance"; + +const DEFAULTS: AppearanceState = { + view: "default", + connectionLayout: "default", + expanded: true, + showPeersNav: true, + showResourcesNav: true, + showExitNodeNav: true, + showProfileSelector: true, + showSettingsButton: true, +}; + +const readStored = (): AppearanceState => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULTS, ...parsed }; + } catch { + return DEFAULTS; + } +}; + +type AppearanceContextValue = AppearanceState & { + setView: (v: AppearanceView) => void; + setField: (k: K, v: AppearanceState[K]) => void; +}; + +const AppearanceContext = createContext(null); + +export const useAppearance = () => { + const ctx = useContext(AppearanceContext); + if (!ctx) { + throw new Error("useAppearance must be used inside AppearanceProvider"); + } + return ctx; +}; + +export const AppearanceProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState(() => readStored()); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // ignore quota / unavailable storage + } + }, [state]); + + const setField = useCallback( + (k: K, v: AppearanceState[K]) => { + setState((s) => ({ ...s, [k]: v })); + }, + [], + ); + + const setView = useCallback((v: AppearanceView) => { + setState((s) => ({ ...s, view: v })); + }, []); + + const value = useMemo( + () => ({ ...state, setView, setField }), + [state, setView, setField], + ); + + return ( + {children} + ); +}; diff --git a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx b/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx index d83892f42..005c517a6 100644 --- a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx +++ b/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx @@ -1,12 +1,45 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { Update as UpdateSvc } from "@bindings/services"; import { useStatus } from "@/hooks/useStatus"; import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner"; +import { UpdatingOverlay } from "@/modules/auto-update/UpdatingOverlay"; type ClientVersionContextValue = { updateAvailable: boolean; updateVersion: string | null; triggerUpdate: () => void; + updating: boolean; + updateError: string | null; + dismissUpdateError: () => void; +}; + +// Dev toggles — flip to preview UI states without triggering real flows. +const FORCE_UPDATE_AVAILABLE = true; +const FORCE_UPDATING = false; +const FORCE_VERSION = "0.65.0"; +// Hide all "update available" UI (header trigger, settings badge, banner) +// regardless of what the daemon reports. +const HIDE_UPDATE_AVAILABLE = false; +// FORCE_ERROR options: +// null → no error (loading state) +// "timeout" → "Update timed out" state +// "cancel" → "Update canceled" state +// "fail" → generic "Update failed" state (uses FORCE_ERROR_MSG) +type ForceError = "timeout" | "cancel" | "fail" | null; +const FORCE_ERROR = null as ForceError; +const FORCE_ERROR_MSG = "installer exited with code 1"; + +const forcedErrorMessage = (): string | null => { + switch (FORCE_ERROR) { + case "timeout": + return "update timed out after 15m"; + case "cancel": + return "update canceled by user"; + case "fail": + return FORCE_ERROR_MSG; + default: + return null; + } }; const ClientVersionContext = createContext(null); @@ -21,25 +54,60 @@ export const useClientVersion = () => { export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { const { status } = useStatus(); + const [updating, setUpdating] = useState(false); + const [updateError, setUpdateError] = useState(null); - const value = useMemo(() => { - const version = (status?.events ?? []) - .map((e) => e.metadata?.["new_version_available"]) - .find((v): v is string => Boolean(v)); - - return { - updateAvailable: Boolean(version), - updateVersion: version ?? null, - triggerUpdate: () => { - UpdateSvc.Trigger().catch(() => {}); - }, - }; + const updateVersion = useMemo(() => { + if (HIDE_UPDATE_AVAILABLE) return null; + if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) return FORCE_VERSION; + return ( + (status?.events ?? []) + .map((e) => e.metadata?.["new_version_available"]) + .find((v): v is string => Boolean(v)) ?? null + ); }, [status]); + const triggerUpdate = useCallback(() => { + setUpdateError(null); + setUpdating(true); + UpdateSvc.Trigger() + .then((result) => { + if (!result?.success) { + setUpdateError(result?.errorMsg || "Update failed"); + setUpdating(false); + } + }) + .catch((e: unknown) => { + setUpdateError(String(e)); + setUpdating(false); + }); + }, []); + + const dismissUpdateError = useCallback(() => setUpdateError(null), []); + + const value = useMemo( + () => ({ + updateAvailable: Boolean(updateVersion), + updateVersion, + triggerUpdate, + updating, + updateError, + dismissUpdateError, + }), + [updateVersion, triggerUpdate, updating, updateError, dismissUpdateError], + ); + return ( {children} + {(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && ( + + )} ); }; diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx index 1b630b757..58db3dab1 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx @@ -1,14 +1,21 @@ +import { forwardRef, type HTMLAttributes } from "react"; import { ArrowUpCircleIcon } from "lucide-react"; import { cn } from "@/lib/cn"; -type Props = { +type Props = HTMLAttributes & { size?: number; - className?: string; }; -export const UpdateBadge = ({ size = 15, className }: Props) => { +export const UpdateBadge = forwardRef(function UpdateBadge( + { size = 15, className, ...rest }, + ref, +) { return ( -
+
{
); -}; +}); diff --git a/client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx b/client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx new file mode 100644 index 000000000..aa1cef7de --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx @@ -0,0 +1,110 @@ +import { Loader2, XCircle } from "lucide-react"; +import { Button } from "@/components/Button"; + +type Props = { + version: string | null; + error: string | null; + onDismiss: () => void; +}; + +type Variant = { + title: string; + description: string; + message?: string; +}; + +function classifyError(msg: string, version: string | null): Variant { + const lower = msg.toLowerCase(); + const target = version ? `v${version}` : "the new version"; + if (lower.includes("timeout") || lower.includes("timed out")) { + return { + title: "Update Is Taking Too Long", + description: `Installing ${target} took too long and didn't finish.`, + }; + } + if (lower.includes("cancel")) { + return { + title: "Update Was Stopped", + description: `The update to ${target} was canceled before it finished.`, + }; + } + return { + title: "Couldn't Install the Update", + description: `${target} couldn't be installed.`, + message: msg || "unknown error", + }; +} + +export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => { + const isError = Boolean(error); + const errorInfo = error ? classifyError(error, version) : null; + + return ( +
{ + if (isError) return; + e.preventDefault(); + e.stopPropagation(); + }} + onKeyDown={(e) => { + if (isError) return; + e.preventDefault(); + e.stopPropagation(); + }} + > +
+ {isError ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+

+ {isError + ? errorInfo!.title + : version + ? `Updating NetBird to v${version}` + : "Updating NetBird"} +

+

+ {isError ? ( + <> + {errorInfo!.description} + {errorInfo!.message && ( + <> +
+ + {errorInfo!.message} + + + )} + + ) : ( + "A newer version is available and is being installed. NetBird will restart automatically once the update is finished." + )} +

+
+ + {isError && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/client/ui/frontend/src/modules/settings/Settings.tsx b/client/ui/frontend/src/modules/settings/Settings.tsx index 841aebfa5..176625119 100644 --- a/client/ui/frontend/src/modules/settings/Settings.tsx +++ b/client/ui/frontend/src/modules/settings/Settings.tsx @@ -7,6 +7,7 @@ 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 { SettingsAppearance } from "@/modules/settings/SettingsAppearance.tsx"; import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; @@ -14,15 +15,35 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; +const LAST_TAB_KEY = "netbird:settings:lastTab"; + +const readLastTab = () => { + try { + return localStorage.getItem(LAST_TAB_KEY); + } catch { + return null; + } +}; + export const Settings = () => { const location = useLocation(); const navState = location.state as { tab?: string } | null; - const [active, setActive] = useState(navState?.tab ?? "general"); + const [active, setActive] = useState( + () => navState?.tab ?? readLastTab() ?? "general", + ); useEffect(() => { if (navState?.tab) setActive(navState.tab); }, [navState?.tab, location.key]); + useEffect(() => { + try { + localStorage.setItem(LAST_TAB_KEY, active); + } catch { + // ignore quota / unavailable storage + } + }, [active]); + return ( @@ -37,6 +58,9 @@ export const Settings = () => { + + + diff --git a/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx b/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx new file mode 100644 index 000000000..e740bf424 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAppearance.tsx @@ -0,0 +1,88 @@ +import FancyToggleSwitch from "@/components/FancyToggleSwitch"; +import { CardSelect } from "@/components/CardSelect.tsx"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { + useAppearance, + type AppearanceView, +} from "@/modules/appearance/AppearanceContext.tsx"; +import simpleScreen from "@/assets/screens/simple.png"; +import advancedScreen from "@/assets/screens/advanced.png"; + +const ScreenPreview = ({ src, alt }: { src: string; alt: string }) => ( + {alt} +); + +export function SettingsAppearance() { + const { + view, + setView, + showPeersNav, + showResourcesNav, + showExitNodeNav, + showProfileSelector, + showSettingsButton, + setField, + } = useAppearance(); + + return ( + <> + + setView(v as AppearanceView)} + > + } + /> + } + /> + + + + + setField("showPeersNav", v)} + label={"Peers"} + helpText={"Show the Peers item in the side navigation."} + /> + setField("showResourcesNav", v)} + label={"Resources"} + helpText={"Show the Resources item in the side navigation."} + /> + setField("showExitNodeNav", v)} + label={"Exit Node"} + helpText={"Show the active exit node in the side navigation."} + /> + setField("showProfileSelector", v)} + label={"Profile Selector"} + helpText={"Show the profile selector in the header."} + /> + setField("showSettingsButton", v)} + label={"Settings Button"} + helpText={"Show the settings button in the header."} + /> + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx index 3a751beb1..97e160fa1 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx @@ -10,6 +10,7 @@ import { ShieldIcon, SlidersHorizontalIcon, SquareTerminalIcon, + SwatchBookIcon, } from "lucide-react"; export const SettingsNavigationTriggers = () => { @@ -29,6 +30,11 @@ export const SettingsNavigationTriggers = () => { icon={SlidersHorizontalIcon} title={"General"} /> + +
+ +
+

Session expired

+

+ Your NetBird session has expired. Sign in again to keep your devices connected. +

+
+ + +
+
+ ); +} diff --git a/client/ui/main.go b/client/ui/main.go index 21d53dd9c..2912eafae 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -127,9 +127,7 @@ func main() { window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "NetBird", Width: 925, - MinWidth: 925, Height: 615, - MinHeight: 615, Hidden: true, BackgroundColour: application.NewRGB(24, 26, 29), URL: "/",