This commit is contained in:
Eduard Gert
2026-05-13 10:11:38 +02:00
parent c8e18585c6
commit 1c8a6e3798
24 changed files with 959 additions and 96 deletions

View File

@@ -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(
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route path="/session-expired" element={<SessionExpired />} />
<Route element={<AppLayout />}>
<Route index element={<Main />} />
<Route path="settings" element={<Settings />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -0,0 +1,34 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { generateColorFromString } from "@/lib/color";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
name?: string;
size?: number;
};
export const Avatar = forwardRef<HTMLButtonElement, Props>(function Avatar(
{ name = "", size = 28, className, type = "button", ...props },
ref,
) {
const initial = (name.trim().charAt(0) || "?").toUpperCase();
const color = generateColorFromString(name);
return (
<button
ref={ref}
type={type}
className={cn(
"flex items-center justify-center rounded-full bg-nb-gray-900",
"text-xs font-semibold cursor-default outline-none",
"transition-colors duration-150 hover:bg-nb-gray-850",
"data-[state=open]:bg-nb-gray-850",
className,
)}
style={{ width: size, height: size, color }}
{...props}
>
{initial}
</button>
);
});

View File

@@ -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(
<AnimatePresence>
{open && (
<div className={"fixed inset-0 z-50"}>
<motion.div
className={"absolute inset-0 bg-black/40 backdrop-blur-sm"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
onClick={() => onOpenChange(false)}
/>
<motion.div
role={"dialog"}
aria-modal={"true"}
className={cn(
"absolute left-0 right-0 bottom-0",
"bg-nb-gray-925 border-t border-nb-gray-850 rounded-t-2xl",
"shadow-2xl outline-none",
"max-h-[85vh] overflow-hidden",
className,
)}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 360, damping: 34 }}
>
<div className={"flex justify-center pt-2"}>
<div className={"h-1 w-10 rounded-full bg-nb-gray-700"} />
</div>
<div className={"px-5 pt-4 pb-6"}>{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
};

View File

@@ -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",

View File

@@ -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 (
<RadioGroup.Root
value={value}
onValueChange={onChange}
className={cn("grid grid-cols-2 gap-3", className)}
>
{children}
</RadioGroup.Root>
);
};
type OptionProps = {
value: string;
title: string;
description?: string;
preview?: ReactNode;
className?: string;
};
const Option = ({ value, title, description, preview, className }: OptionProps) => {
return (
<RadioGroup.Item
value={value}
className={cn(
"group relative flex flex-col items-stretch text-left rounded-lg",
"border border-nb-gray-850 bg-nb-gray-925 p-3 cursor-default outline-none",
"transition-colors duration-150",
"hover:border-nb-gray-800",
"data-[state=checked]:border-netbird data-[state=checked]:ring-1 data-[state=checked]:ring-netbird",
className,
)}
>
<span
className={cn(
"absolute top-2.5 right-2.5 flex h-4 w-4 items-center justify-center rounded-[4px]",
"border border-nb-gray-700 bg-nb-gray-900",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator className={"flex items-center justify-center"}>
<CheckIcon size={11} className={"text-white"} strokeWidth={3} />
</RadioGroup.Indicator>
</span>
<div
className={cn(
"h-48 -mx-3 -mt-3 mb-3 overflow-hidden",
"bg-gradient-to-b from-nb-gray-800/15 to-nb-gray",
"rounded-t-lg flex items-center justify-center",
)}
>
{preview}
</div>
<h3 className={"text-sm font-semibold text-nb-gray-100"}>{title}</h3>
{description && (
<p className={"text-[0.72rem] leading-snug text-nb-gray-400 mt-0.5"}>
{description}
</p>
)}
</RadioGroup.Item>
);
};
export const CardSelect = Object.assign(Root, { Option });

View File

@@ -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]",
},
},
});

View File

@@ -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 {

View File

@@ -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 (
<div className={"relative flex h-full flex-col"}>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Header />
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
<AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Header />
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</AppearanceProvider>
</div>
);
};

View File

@@ -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, string> = {
[ConnectionState.Disconnected]: "Disconnected",
[ConnectionState.Connecting]: "Connecting...",
[ConnectionState.Connected]: "Connected",
[ConnectionState.Disconnecting]: "Disconnecting...",
};
export const ConnectionStatus = () => {
const [state, setState] = useState<ConnectionState>(ConnectionState.Disconnected);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className={"flex flex-col h-full items-center justify-center"}>
<NetBirdConnectToggle state={ConnectionState.Connected} />
<h1
className={
"text-base font-medium mt-8 text-nb-gray-200 tracking-wide"
}
>
Connected
</h1>
<p className={"font-mono text-xs text-nb-gray-300 mt-1"}>
peer-hostname.netbird.cloud
</p>
<p className={"font-mono text-xs text-nb-gray-300 mt-0.5"}>
192.168.0.1
</p>
<div className={cn("flex flex-col h-full w-full items-center justify-between", "-mt-4")}>
<div className={"w-full h-full flex flex-col items-center justify-center"}>
<div className={"flex flex-col items-center justify-center"}>
<p
className={
"font-mono text-xs text-nb-gray-300 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
peer-hostname.netbird.cloud
</p>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-0.5 mb-6 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
192.168.0.1
</p>
</div>
<NetBirdConnectToggle state={state} onClick={handleToggleClick} />
<div
className={
"flex flex-col w-full items-center justify-center gap-3 p-4 rounded-2xl mt-2"
}
>
<h1 className={"text-sm font-medium text-nb-gray-200 tracking-wide"}>
{STATUS_LABEL[state]}
</h1>
<div className={"w-full"}>
<Button
variant={buttonVariant}
size={"xs"}
className={"w-full"}
disabled={isTransitioning}
onClick={handleButtonClick}
>
{buttonLabel}
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -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, string> = {
[ConnectionState.Disconnected]: "Disconnected",
[ConnectionState.Connecting]: "Connecting...",
[ConnectionState.Connected]: "Connected",
[ConnectionState.Disconnecting]: "Disconnecting...",
};
export const ConnectionStatusSwitch = () => {
const [state, setState] = useState<ConnectionState>(ConnectionState.Disconnected);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
<img
src={netbirdFullLogo}
alt={"NetBird"}
className={"h-7 w-auto select-none mb-4"}
draggable={false}
/>
<ToggleSwitch
size={"large"}
checked={isOn}
onCheckedChange={handleSwitch}
disabled={isTransitioning}
className={cn(isTransitioning && "opacity-80")}
/>
<div className={"flex flex-col items-center"}>
<h1
className={
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300"
}
>
{STATUS_LABEL[state]}
</h1>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-2 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
peer-hostname.netbird.cloud
</p>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-0.5 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
>
192.168.0.1
</p>
</div>
</div>
);
};

View File

@@ -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 (
<div
className={
"pt-4 shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15"
}
className={cn(
"shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15",
"pt-4",
)}
>
<div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} />
</div>
<UpdateHeaderTrigger />
{showProfileSelector && (
<div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} />
</div>
)}
<IconButton
icon={SettingsIcon}
onClick={() => 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 && (
<IconButton
icon={SettingsIcon}
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
className={cn(
isSettingsPage &&
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
)}
/>
)}
</div>
);
};

View File

@@ -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 (
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div
className={
"flex flex-col max-w-xs w-full shrink-0 items-center"
}
className={cn(
"flex flex-col w-full shrink-0 items-center",
expanded && "max-w-xs",
)}
>
<ConnectionStatus />
{connectionLayout === "switch" ? (
<ConnectionStatusSwitch />
) : (
<ConnectionStatus />
)}
<Navigation peersActive />
</div>
<MainRightSide>
<Peers />
</MainRightSide>
{expanded && (
<MainRightSide>
<Peers />
</MainRightSide>
)}
</div>
);
};

View File

@@ -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 (
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
<CardNavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}
description={"17 of 25 Online"}
active={peersActive}
onClick={onPeersClick}
/>
<CardNavItem
icon={Layers3Icon}
title={"Resources"}
description={"13 of 16 Active"}
iconSize={14}
/>
<CardNavItem
iconNode={
<img
src={deFlag}
alt={"Germany"}
className={"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"}
/>
}
title={"Exit Node Berlin"}
description={"100.92.14.37"}
/>
{showPeersNav && (
<CardNavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}
description={"17 of 25 Online"}
active={peersActive}
onClick={onPeersClick}
/>
)}
{showResourcesNav && (
<CardNavItem
icon={Layers3Icon}
title={"Resources"}
description={"13 of 16 Active"}
iconSize={14}
/>
)}
{showExitNodeNav && (
<CardNavItem
iconNode={
<img
src={deFlag}
alt={"Germany"}
className={
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
}
/>
}
title={"Exit Node Berlin"}
description={"100.92.14.37"}
/>
)}
</nav>
);
};

View File

@@ -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<AppearanceState>;
return { ...DEFAULTS, ...parsed };
} catch {
return DEFAULTS;
}
};
type AppearanceContextValue = AppearanceState & {
setView: (v: AppearanceView) => void;
setField: <K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => void;
};
const AppearanceContext = createContext<AppearanceContextValue | null>(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<AppearanceState>(() => readStored());
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// ignore quota / unavailable storage
}
}, [state]);
const setField = useCallback(
<K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => {
setState((s) => ({ ...s, [k]: v }));
},
[],
);
const setView = useCallback((v: AppearanceView) => {
setState((s) => ({ ...s, view: v }));
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({ ...state, setView, setField }),
[state, setView, setField],
);
return (
<AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>
);
};

View File

@@ -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<ClientVersionContextValue | null>(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<string | null>(null);
const value = useMemo<ClientVersionContextValue>(() => {
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<ClientVersionContextValue>(
() => ({
updateAvailable: Boolean(updateVersion),
updateVersion,
triggerUpdate,
updating,
updateError,
dismissUpdateError,
}),
[updateVersion, triggerUpdate, updating, updateError, dismissUpdateError],
);
return (
<ClientVersionContext.Provider value={value}>
{children}
<UpdateAvailableBanner />
{(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && (
<UpdatingOverlay
version={updateVersion}
error={updateError ?? forcedErrorMessage()}
onDismiss={dismissUpdateError}
/>
)}
</ClientVersionContext.Provider>
);
};

View File

@@ -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<HTMLDivElement> & {
size?: number;
className?: string;
};
export const UpdateBadge = ({ size = 15, className }: Props) => {
export const UpdateBadge = forwardRef<HTMLDivElement, Props>(function UpdateBadge(
{ size = 15, className, ...rest },
ref,
) {
return (
<div className={cn("relative flex items-center justify-center", className)}>
<div
ref={ref}
className={cn("relative flex items-center justify-center", className)}
{...rest}
>
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
@@ -17,4 +24,4 @@ export const UpdateBadge = ({ size = 15, className }: Props) => {
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
</div>
);
};
});

View File

@@ -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 (
<div
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950/85 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onPointerDown={(e) => {
if (isError) return;
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (isError) return;
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
{isError ? (
<div
className={"h-9 w-9 rounded-md flex items-center justify-center bg-red-500"}
>
<XCircle className={"text-white"} size={18} />
</div>
) : (
<div
className={"h-9 w-9 rounded-md flex items-center justify-center bg-nb-gray-100"}
>
<Loader2 className={"animate-spin text-nb-gray-950"} size={16} />
</div>
)}
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{isError
? errorInfo!.title
: version
? `Updating NetBird to v${version}`
: "Updating NetBird"}
</p>
<p className={"text-sm text-nb-gray-300"}>
{isError ? (
<>
{errorInfo!.description}
{errorInfo!.message && (
<>
<br />
<span className={"first-letter:uppercase"}>
{errorInfo!.message}
</span>
</>
)}
</>
) : (
"A newer version is available and is being installed. NetBird will restart automatically once the update is finished."
)}
</p>
</div>
{isError && (
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={onDismiss}>
Close
</Button>
</div>
)}
</div>
</div>
);
};

View File

@@ -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 (
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
<SettingsNavigationTriggers />
@@ -37,6 +58,9 @@ export const Settings = () => {
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"appearance"}>
<SettingsAppearance />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>

View File

@@ -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 }) => (
<img
src={src}
alt={alt}
draggable={false}
className={"h-full w-full object-contain select-none"}
/>
);
export function SettingsAppearance() {
const {
view,
setView,
showPeersNav,
showResourcesNav,
showExitNodeNav,
showProfileSelector,
showSettingsButton,
setField,
} = useAppearance();
return (
<>
<SectionGroup title={"View"}>
<CardSelect
value={view}
onChange={(v) => setView(v as AppearanceView)}
>
<CardSelect.Option
value={"default"}
title={"Simple"}
description={"Streamlined view with essential controls."}
preview={<ScreenPreview src={simpleScreen} alt={"Simple view"} />}
/>
<CardSelect.Option
value={"advanced"}
title={"Advanced"}
description={"All details and power-user options visible."}
preview={<ScreenPreview src={advancedScreen} alt={"Advanced view"} />}
/>
</CardSelect>
</SectionGroup>
<SectionGroup title={"Interface"}>
<FancyToggleSwitch
value={showPeersNav}
onChange={(v) => setField("showPeersNav", v)}
label={"Peers"}
helpText={"Show the Peers item in the side navigation."}
/>
<FancyToggleSwitch
value={showResourcesNav}
onChange={(v) => setField("showResourcesNav", v)}
label={"Resources"}
helpText={"Show the Resources item in the side navigation."}
/>
<FancyToggleSwitch
value={showExitNodeNav}
onChange={(v) => setField("showExitNodeNav", v)}
label={"Exit Node"}
helpText={"Show the active exit node in the side navigation."}
/>
<FancyToggleSwitch
value={showProfileSelector}
onChange={(v) => setField("showProfileSelector", v)}
label={"Profile Selector"}
helpText={"Show the profile selector in the header."}
/>
<FancyToggleSwitch
value={showSettingsButton}
onChange={(v) => setField("showSettingsButton", v)}
label={"Settings Button"}
helpText={"Show the settings button in the header."}
/>
</SectionGroup>
</>
);
}

View File

@@ -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"}
/>
<VerticalTabs.Trigger
value={"appearance"}
icon={SwatchBookIcon}
title={"Appearance"}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}

View File

@@ -0,0 +1,32 @@
import { ShieldAlertIcon } from "lucide-react";
import { Button } from "@/components/Button";
export default function SessionExpired() {
return (
<div
className={
"h-full w-full flex flex-col items-center justify-center text-center px-6 py-8 bg-nb-gray-950"
}
>
<div
className={
"h-12 w-12 rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird mb-4"
}
>
<ShieldAlertIcon size={22} />
</div>
<h1 className={"text-base font-semibold text-nb-gray-100"}>Session expired</h1>
<p className={"text-xs text-nb-gray-400 mt-1.5 max-w-[20rem] leading-snug"}>
Your NetBird session has expired. Sign in again to keep your devices connected.
</p>
<div className={"flex gap-2 mt-5 w-full max-w-[18rem]"}>
<Button variant={"secondary"} size={"xs"} className={"flex-1"}>
Later
</Button>
<Button variant={"primary"} size={"xs"} className={"flex-1"}>
Sign in
</Button>
</div>
</div>
);
}

View File

@@ -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: "/",