mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-13 20:29:55 +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 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 />} />
|
||||
|
||||
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: {
|
||||
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",
|
||||
|
||||
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: {
|
||||
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]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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 { 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>
|
||||
|
||||
@@ -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,
|
||||
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}
|
||||
|
||||
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{
|
||||
Title: "NetBird",
|
||||
Width: 925,
|
||||
MinWidth: 925,
|
||||
Height: 615,
|
||||
MinHeight: 615,
|
||||
Hidden: true,
|
||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||
URL: "/",
|
||||
|
||||
Reference in New Issue
Block a user