mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 13:49:58 +00:00
wip
This commit is contained in:
@@ -4,6 +4,7 @@ import "./globals.css";
|
|||||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import QuickActions from "@/screens/QuickActions.tsx";
|
import QuickActions from "@/screens/QuickActions.tsx";
|
||||||
import LoginUrl from "@/pages/LoginUrl.tsx";
|
import LoginUrl from "@/pages/LoginUrl.tsx";
|
||||||
|
import SessionExpired from "@/pages/SessionExpired.tsx";
|
||||||
import Update from "@/screens/Update.tsx";
|
import Update from "@/screens/Update.tsx";
|
||||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||||
import { Main } from "@/layouts/Main.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="/quick" element={<QuickActions />} />
|
||||||
<Route path="/login" element={<LoginUrl />} />
|
<Route path="/login" element={<LoginUrl />} />
|
||||||
<Route path="/update" element={<Update />} />
|
<Route path="/update" element={<Update />} />
|
||||||
|
<Route path="/session-expired" element={<SessionExpired />} />
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<Main />} />
|
<Route index element={<Main />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
|||||||
BIN
client/ui/frontend/src/assets/screens/advanced.png
Normal file
BIN
client/ui/frontend/src/assets/screens/advanced.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
client/ui/frontend/src/assets/screens/simple.png
Normal file
BIN
client/ui/frontend/src/assets/screens/simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
34
client/ui/frontend/src/components/Avatar.tsx
Normal file
34
client/ui/frontend/src/components/Avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
60
client/ui/frontend/src/components/BottomSheet.tsx
Normal file
60
client/ui/frontend/src/components/BottomSheet.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -91,7 +91,7 @@ export const buttonVariants = cva(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: {
|
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",
|
xs2: "text-[0.78rem] py-2 px-4",
|
||||||
sm: "text-sm py-[9px] px-4",
|
sm: "text-sm py-[9px] px-4",
|
||||||
md: "text-md py-[9px] px-4",
|
md: "text-md py-[9px] px-4",
|
||||||
|
|||||||
76
client/ui/frontend/src/components/CardSelect.tsx
Normal file
76
client/ui/frontend/src/components/CardSelect.tsx
Normal 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 });
|
||||||
@@ -12,6 +12,7 @@ const switchVariants = cva("", {
|
|||||||
size: {
|
size: {
|
||||||
default: "h-[24px] w-[44px]",
|
default: "h-[24px] w-[44px]",
|
||||||
small: "h-[18px] w-[36px]",
|
small: "h-[18px] w-[36px]",
|
||||||
|
large: "h-[36px] w-[66px]",
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
default: [
|
default: [
|
||||||
@@ -36,6 +37,7 @@ const switchVariants = cva("", {
|
|||||||
"thumb-size": {
|
"thumb-size": {
|
||||||
default: "h-5 w-5 data-[state=checked]:translate-x-5",
|
default: "h-5 w-5 data-[state=checked]:translate-x-5",
|
||||||
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
|
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
|
||||||
|
large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[30px]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
.wails-draggable {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Header } from "@/layouts/Header.tsx";
|
import { Header } from "@/layouts/Header.tsx";
|
||||||
|
import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx";
|
||||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||||
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||||
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||||
@@ -7,14 +8,16 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
|||||||
export const AppLayout = () => {
|
export const AppLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className={"relative flex h-full flex-col"}>
|
<div className={"relative flex h-full flex-col"}>
|
||||||
<ProfileProvider>
|
<AppearanceProvider>
|
||||||
<DebugBundleProvider>
|
<ProfileProvider>
|
||||||
<ClientVersionProvider>
|
<DebugBundleProvider>
|
||||||
<Header />
|
<ClientVersionProvider>
|
||||||
<Outlet />
|
<Header />
|
||||||
</ClientVersionProvider>
|
<Outlet />
|
||||||
</DebugBundleProvider>
|
</ClientVersionProvider>
|
||||||
</ProfileProvider>
|
</DebugBundleProvider>
|
||||||
|
</ProfileProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,117 @@
|
|||||||
import {
|
import { useEffect, useRef, useState } from "react";
|
||||||
ConnectionState,
|
import { ConnectionState, NetBirdConnectToggle } from "@/components/NetBirdConnectToggle.tsx";
|
||||||
NetBirdConnectToggle,
|
import Button from "@/components/Button.tsx";
|
||||||
} from "@/components/NetBirdConnectToggle.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 = () => {
|
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 (
|
return (
|
||||||
<div className={"flex flex-col h-full items-center justify-center"}>
|
<div className={cn("flex flex-col h-full w-full items-center justify-between", "-mt-4")}>
|
||||||
<NetBirdConnectToggle state={ConnectionState.Connected} />
|
<div className={"w-full h-full flex flex-col items-center justify-center"}>
|
||||||
<h1
|
<div className={"flex flex-col items-center justify-center"}>
|
||||||
className={
|
<p
|
||||||
"text-base font-medium mt-8 text-nb-gray-200 tracking-wide"
|
className={
|
||||||
}
|
"font-mono text-xs text-nb-gray-300 transition-opacity duration-300 " +
|
||||||
>
|
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
|
||||||
Connected
|
}
|
||||||
</h1>
|
>
|
||||||
<p className={"font-mono text-xs text-nb-gray-300 mt-1"}>
|
peer-hostname.netbird.cloud
|
||||||
peer-hostname.netbird.cloud
|
</p>
|
||||||
</p>
|
<p
|
||||||
<p className={"font-mono text-xs text-nb-gray-300 mt-0.5"}>
|
className={
|
||||||
192.168.0.1
|
"font-mono text-xs text-nb-gray-300 mt-0.5 mb-6 transition-opacity duration-300 " +
|
||||||
</p>
|
(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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
102
client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx
Normal file
102
client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,33 +1,76 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
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 { ProfileSelector } from "@/components/ProfileSelector.tsx";
|
||||||
import { IconButton } from "@/components/IconButton.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";
|
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 = () => {
|
export const Header = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isSettingsPage = location.pathname.startsWith("/settings");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={cn(
|
||||||
"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"
|
"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"}>
|
{showProfileSelector && (
|
||||||
<ProfileSelector email={"eduard@netbird.io"} />
|
<div className={"ml-20"}>
|
||||||
</div>
|
<ProfileSelector email={"eduard@netbird.io"} />
|
||||||
<UpdateHeaderTrigger />
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={SettingsIcon}
|
icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon}
|
||||||
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
|
onClick={togglePanel}
|
||||||
className={cn(
|
|
||||||
isSettingsPage &&
|
|
||||||
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
|
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
|
||||||
|
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
||||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||||
import { Navigation } from "@/layouts/Navigation.tsx";
|
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||||
import { Peers } from "@/modules/peers/Peers.tsx";
|
import { Peers } from "@/modules/peers/Peers.tsx";
|
||||||
|
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export const Main = () => {
|
export const Main = () => {
|
||||||
|
const { connectionLayout, expanded } = useAppearance();
|
||||||
return (
|
return (
|
||||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={cn(
|
||||||
"flex flex-col max-w-xs w-full shrink-0 items-center"
|
"flex flex-col w-full shrink-0 items-center",
|
||||||
}
|
expanded && "max-w-xs",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ConnectionStatus />
|
{connectionLayout === "switch" ? (
|
||||||
|
<ConnectionStatusSwitch />
|
||||||
|
) : (
|
||||||
|
<ConnectionStatus />
|
||||||
|
)}
|
||||||
<Navigation peersActive />
|
<Navigation peersActive />
|
||||||
</div>
|
</div>
|
||||||
<MainRightSide>
|
{expanded && (
|
||||||
<Peers />
|
<MainRightSide>
|
||||||
</MainRightSide>
|
<Peers />
|
||||||
|
</MainRightSide>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||||
import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react";
|
import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react";
|
||||||
import deFlag from "@/assets/flags/1x1/de.svg";
|
import deFlag from "@/assets/flags/1x1/de.svg";
|
||||||
|
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
peersActive?: boolean;
|
peersActive?: boolean;
|
||||||
@@ -8,32 +9,42 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
||||||
|
const { showPeersNav, showResourcesNav, showExitNodeNav } = useAppearance();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||||
<CardNavItem
|
{showPeersNav && (
|
||||||
icon={MonitorSmartphoneIcon}
|
<CardNavItem
|
||||||
title={"Peers"}
|
icon={MonitorSmartphoneIcon}
|
||||||
description={"17 of 25 Online"}
|
title={"Peers"}
|
||||||
active={peersActive}
|
description={"17 of 25 Online"}
|
||||||
onClick={onPeersClick}
|
active={peersActive}
|
||||||
/>
|
onClick={onPeersClick}
|
||||||
<CardNavItem
|
/>
|
||||||
icon={Layers3Icon}
|
)}
|
||||||
title={"Resources"}
|
{showResourcesNav && (
|
||||||
description={"13 of 16 Active"}
|
<CardNavItem
|
||||||
iconSize={14}
|
icon={Layers3Icon}
|
||||||
/>
|
title={"Resources"}
|
||||||
<CardNavItem
|
description={"13 of 16 Active"}
|
||||||
iconNode={
|
iconSize={14}
|
||||||
<img
|
/>
|
||||||
src={deFlag}
|
)}
|
||||||
alt={"Germany"}
|
{showExitNodeNav && (
|
||||||
className={"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"}
|
<CardNavItem
|
||||||
/>
|
iconNode={
|
||||||
}
|
<img
|
||||||
title={"Exit Node Berlin"}
|
src={deFlag}
|
||||||
description={"100.92.14.37"}
|
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>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 { Update as UpdateSvc } from "@bindings/services";
|
||||||
import { useStatus } from "@/hooks/useStatus";
|
import { useStatus } from "@/hooks/useStatus";
|
||||||
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner";
|
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner";
|
||||||
|
import { UpdatingOverlay } from "@/modules/auto-update/UpdatingOverlay";
|
||||||
|
|
||||||
type ClientVersionContextValue = {
|
type ClientVersionContextValue = {
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
updateVersion: string | null;
|
updateVersion: string | null;
|
||||||
triggerUpdate: () => void;
|
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);
|
const ClientVersionContext = createContext<ClientVersionContextValue | null>(null);
|
||||||
@@ -21,25 +54,60 @@ export const useClientVersion = () => {
|
|||||||
|
|
||||||
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const { status } = useStatus();
|
const { status } = useStatus();
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
const value = useMemo<ClientVersionContextValue>(() => {
|
const updateVersion = useMemo(() => {
|
||||||
const version = (status?.events ?? [])
|
if (HIDE_UPDATE_AVAILABLE) return null;
|
||||||
.map((e) => e.metadata?.["new_version_available"])
|
if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) return FORCE_VERSION;
|
||||||
.find((v): v is string => Boolean(v));
|
return (
|
||||||
|
(status?.events ?? [])
|
||||||
return {
|
.map((e) => e.metadata?.["new_version_available"])
|
||||||
updateAvailable: Boolean(version),
|
.find((v): v is string => Boolean(v)) ?? null
|
||||||
updateVersion: version ?? null,
|
);
|
||||||
triggerUpdate: () => {
|
|
||||||
UpdateSvc.Trigger().catch(() => {});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [status]);
|
}, [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 (
|
return (
|
||||||
<ClientVersionContext.Provider value={value}>
|
<ClientVersionContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<UpdateAvailableBanner />
|
<UpdateAvailableBanner />
|
||||||
|
{(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && (
|
||||||
|
<UpdatingOverlay
|
||||||
|
version={updateVersion}
|
||||||
|
error={updateError ?? forcedErrorMessage()}
|
||||||
|
onDismiss={dismissUpdateError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ClientVersionContext.Provider>
|
</ClientVersionContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes } from "react";
|
||||||
import { ArrowUpCircleIcon } from "lucide-react";
|
import { ArrowUpCircleIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
type Props = {
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
size?: number;
|
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 (
|
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
|
<span
|
||||||
className={
|
className={
|
||||||
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
|
"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"} />
|
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
110
client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx
Normal file
110
client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
|||||||
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
||||||
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
|
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
|
||||||
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||||
|
import { SettingsAppearance } from "@/modules/settings/SettingsAppearance.tsx";
|
||||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.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 { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.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 = () => {
|
export const Settings = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navState = location.state as { tab?: string } | null;
|
const navState = location.state as { tab?: string } | null;
|
||||||
const [active, setActive] = useState(navState?.tab ?? "general");
|
const [active, setActive] = useState(
|
||||||
|
() => navState?.tab ?? readLastTab() ?? "general",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navState?.tab) setActive(navState.tab);
|
if (navState?.tab) setActive(navState.tab);
|
||||||
}, [navState?.tab, location.key]);
|
}, [navState?.tab, location.key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_TAB_KEY, active);
|
||||||
|
} catch {
|
||||||
|
// ignore quota / unavailable storage
|
||||||
|
}
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||||
<SettingsNavigationTriggers />
|
<SettingsNavigationTriggers />
|
||||||
@@ -37,6 +58,9 @@ export const Settings = () => {
|
|||||||
<VerticalTabs.Content value={"general"}>
|
<VerticalTabs.Content value={"general"}>
|
||||||
<SettingsGeneral />
|
<SettingsGeneral />
|
||||||
</VerticalTabs.Content>
|
</VerticalTabs.Content>
|
||||||
|
<VerticalTabs.Content value={"appearance"}>
|
||||||
|
<SettingsAppearance />
|
||||||
|
</VerticalTabs.Content>
|
||||||
<VerticalTabs.Content value={"network"}>
|
<VerticalTabs.Content value={"network"}>
|
||||||
<SettingsNetwork />
|
<SettingsNetwork />
|
||||||
</VerticalTabs.Content>
|
</VerticalTabs.Content>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
SquareTerminalIcon,
|
SquareTerminalIcon,
|
||||||
|
SwatchBookIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const SettingsNavigationTriggers = () => {
|
export const SettingsNavigationTriggers = () => {
|
||||||
@@ -29,6 +30,11 @@ export const SettingsNavigationTriggers = () => {
|
|||||||
icon={SlidersHorizontalIcon}
|
icon={SlidersHorizontalIcon}
|
||||||
title={"General"}
|
title={"General"}
|
||||||
/>
|
/>
|
||||||
|
<VerticalTabs.Trigger
|
||||||
|
value={"appearance"}
|
||||||
|
icon={SwatchBookIcon}
|
||||||
|
title={"Appearance"}
|
||||||
|
/>
|
||||||
<VerticalTabs.Trigger
|
<VerticalTabs.Trigger
|
||||||
value={"network"}
|
value={"network"}
|
||||||
icon={NetworkIcon}
|
icon={NetworkIcon}
|
||||||
|
|||||||
32
client/ui/frontend/src/pages/SessionExpired.tsx
Normal file
32
client/ui/frontend/src/pages/SessionExpired.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,9 +127,7 @@ func main() {
|
|||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "NetBird",
|
Title: "NetBird",
|
||||||
Width: 925,
|
Width: 925,
|
||||||
MinWidth: 925,
|
|
||||||
Height: 615,
|
Height: 615,
|
||||||
MinHeight: 615,
|
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||||
URL: "/",
|
URL: "/",
|
||||||
|
|||||||
Reference in New Issue
Block a user