mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-12 03:39:55 +00:00
add general settings
This commit is contained in:
7
client/ui-wails/frontend/.prettierignore
Normal file
7
client/ui-wails/frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
wailsjs
|
||||
*.min.js
|
||||
*.min.css
|
||||
10
client/ui-wails/frontend/.prettierrc
Normal file
10
client/ui-wails/frontend/.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -8,13 +8,16 @@
|
||||
"build:dev": "tsc && vite build --minify false --mode development",
|
||||
"build": "tsc && vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -41,6 +44,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
41
client/ui-wails/frontend/pnpm-lock.yaml
generated
41
client/ui-wails/frontend/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ dependencies:
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
@@ -91,6 +94,9 @@ devDependencies:
|
||||
postcss:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.12
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19
|
||||
@@ -1024,6 +1030,35 @@ packages:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
|
||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
@@ -2151,6 +2186,12 @@ packages:
|
||||
source-map-js: 1.2.1
|
||||
dev: true
|
||||
|
||||
/prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
dev: true
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Main } from "@/layouts/Main.tsx";
|
||||
import { Settings } from "@/modules/settings/Settings.tsx";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
|
||||
welcome();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -4,9 +4,7 @@ import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonVariants {
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
@@ -26,7 +24,7 @@ export const buttonVariants = cva(
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-900 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
@@ -88,9 +86,9 @@ export const buttonVariants = cva(
|
||||
size: {
|
||||
xs: "text-xs py-2 px-4",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
sm: "text-sm py-2.5 px-4",
|
||||
md: "text-md py-2.5 px-4",
|
||||
lg: "text-lg py-2.5 px-4",
|
||||
sm: "text-sm py-[9px] px-4",
|
||||
md: "text-md py-[9px] px-4",
|
||||
lg: "text-lg py-[9px] px-4",
|
||||
},
|
||||
rounded: {
|
||||
true: "rounded-md",
|
||||
@@ -105,47 +103,45 @@ export const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(
|
||||
{
|
||||
variant = "default",
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
type = "button",
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
buttonVariants({
|
||||
variant,
|
||||
rounded,
|
||||
border: border ? 1 : 0,
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
variant = "default",
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
type = "button",
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
);
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
buttonVariants({
|
||||
variant,
|
||||
rounded,
|
||||
border: border ? 1 : 0,
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function FancyToggleSwitch({
|
||||
role={"switch"}
|
||||
aria-checked={value}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all duration-300 relative z-[1]",
|
||||
"cursor-default transition-all duration-300 relative z-[1]",
|
||||
"inline-block text-left w-full",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
className,
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
export const HelpText = ({ children, margin = true, className }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[.8rem] dark:text-nb-gray-300 block font-light tracking-wide",
|
||||
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
|
||||
margin && "mb-2",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { AlertCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
@@ -93,9 +93,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
|
||||
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -104,7 +104,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
? "error"
|
||||
: "default",
|
||||
}),
|
||||
"flex h-[42px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName,
|
||||
@@ -134,7 +134,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
}),
|
||||
"flex h-[42px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
@@ -161,7 +161,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||
<AlertCircle size={13} />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
|
||||
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-100 flex items-center gap-2",
|
||||
);
|
||||
|
||||
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
|
||||
41
client/ui-wails/frontend/src/components/SwitchItem.tsx
Normal file
41
client/ui-wails/frontend/src/components/SwitchItem.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const SwitchItem = ({ value, children }: Props) => {
|
||||
const { value: activeValue, layoutId } = useSwitchItemGroup();
|
||||
const active = activeValue === value;
|
||||
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||
"outline-none cursor-default",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={layoutId}
|
||||
className={"absolute inset-0 rounded-md bg-nb-gray-800"}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={"relative inline-flex items-center justify-center gap-1"}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
46
client/ui-wails/frontend/src/components/SwitchItemGroup.tsx
Normal file
46
client/ui-wails/frontend/src/components/SwitchItemGroup.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createContext, ReactNode, useContext, useId } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SwitchItemGroupContextValue = {
|
||||
value: string;
|
||||
layoutId: string;
|
||||
};
|
||||
|
||||
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const useSwitchItemGroup = () => {
|
||||
const ctx = useContext(SwitchItemGroupContext);
|
||||
if (!ctx) {
|
||||
throw new Error("SwitchItem must be used inside a SwitchItemGroup");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => {
|
||||
const layoutId = useId();
|
||||
|
||||
return (
|
||||
<SwitchItemGroupContext.Provider value={{ value, layoutId }}>
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className={cn(
|
||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-920 p-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
</SwitchItemGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -16,15 +16,21 @@ const switchVariants = cva("", {
|
||||
variant: {
|
||||
default: [
|
||||
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||
],
|
||||
"red-green": [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||
],
|
||||
red: [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
|
||||
"data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
|
||||
],
|
||||
},
|
||||
"thumb-size": {
|
||||
@@ -45,7 +51,7 @@ const ToggleSwitch = React.forwardRef<
|
||||
) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
"peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -7,7 +8,11 @@ type Props = {
|
||||
export const MainRightSide = ({ children }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={"wails-no-draggable flex-1 min-h-0 min-w-0 flex flex-col bg-nb-gray-935 rounded-xl rounded-br-2xl border border-nb-gray-910"}
|
||||
className={cn(
|
||||
"wails-no-draggable",
|
||||
"bg-nb-gray-935 border border-nb-gray-910",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl ",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
23
client/ui-wails/frontend/src/lib/welcome.ts
Normal file
23
client/ui-wails/frontend/src/lib/welcome.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const ART = `
|
||||
_ __ __ ____ _ __ ______ __ __ __
|
||||
/ | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
|
||||
/ |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
|
||||
/ /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
|
||||
/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
|
||||
`;
|
||||
|
||||
export function welcome() {
|
||||
const message = `%c${ART}%c
|
||||
NetBird — The Only Secure Access Platform You'll Ever Need.
|
||||
|
||||
WEBSITE: https://netbird.io/
|
||||
WE'RE HIRING: https://careers.netbird.io/
|
||||
OPEN SOURCE: https://github.com/netbirdio/netbird
|
||||
`;
|
||||
|
||||
console.log(
|
||||
message,
|
||||
"color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
|
||||
"color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
|
||||
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
onChange: (mode: ManagementMode) => void;
|
||||
};
|
||||
|
||||
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
|
||||
<SwitchItem value={ManagementMode.Cloud}>
|
||||
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
Cloud
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
@@ -15,36 +15,34 @@ export const Settings = () => {
|
||||
const [active, setActive] = useState("general");
|
||||
|
||||
return (
|
||||
<VerticalTabs
|
||||
value={active}
|
||||
onValueChange={setActive}
|
||||
className={"wails-draggable p-4"}
|
||||
>
|
||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||
<SettingsNavigationTriggers />
|
||||
<MainRightSide>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</MainRightSide>
|
||||
</VerticalTabs>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ const SAVE_DEBOUNCE_MS = 400;
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -98,27 +99,37 @@ const useSettingsState = () => {
|
||||
await save(config);
|
||||
}, [config, save]);
|
||||
|
||||
return { config, error, setField, saveNow };
|
||||
const saveField = useCallback(
|
||||
async <K extends keyof Config>(k: K, v: Config[K]) => {
|
||||
if (!config) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const next = { ...config, [k]: v };
|
||||
setConfig(next);
|
||||
await save(next);
|
||||
},
|
||||
[config, save],
|
||||
);
|
||||
|
||||
return { config, error, setField, saveField, saveNow };
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, error, setField, saveNow } = useSettingsState();
|
||||
const { config, error, setField, saveField, saveNow } = useSettingsState();
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<p className={"px-6 py-2 text-sm text-red-500"}>{error}</p>
|
||||
)}
|
||||
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<div className={"p-6 text-sm text-nb-gray-500"}>
|
||||
Loading…
|
||||
</div>
|
||||
<div className={"p-6 text-sm text-nb-gray-500"}>Loading…</div>
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{ config, setField, saveNow }}
|
||||
value={{ config, setField, saveField, saveNow }}
|
||||
>
|
||||
<div className={"px-6 py-5"}>{children}</div>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
@@ -5,54 +6,77 @@ import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { config, setField, saveNow } = useSettings();
|
||||
const { config, setField } = useSettings();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMode = useRef(mode);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevMode.current === ManagementMode.Cloud &&
|
||||
mode === ManagementMode.SelfHosted
|
||||
) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevMode.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"General"}>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableAutoConnect}
|
||||
onChange={(v) => setField("disableAutoConnect", !v)}
|
||||
label={"Connect on startup"}
|
||||
helpText={
|
||||
"Automatically connect to NetBird when the app launches."
|
||||
}
|
||||
label={"Connect on Startup"}
|
||||
helpText={"Automatically establish a connection when the service starts."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
label={"Show notifications"}
|
||||
helpText={
|
||||
"Show desktop notifications for connection events and updates."
|
||||
}
|
||||
label={"Desktop Notifications"}
|
||||
helpText={"Show desktop notifications for new updates and connection events."}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Connection"}>
|
||||
<div>
|
||||
<Label as={"div"}>Management Server</Label>
|
||||
<HelpText>
|
||||
The NetBird management server this client connects to.
|
||||
Saving will reconnect to apply the new server.
|
||||
</HelpText>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<div className={"flex-1"}>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>Management Server</Label>
|
||||
<HelpText>
|
||||
Connect to NetBird Cloud or your own self-hosted management server.
|
||||
Changes will reconnect the client.
|
||||
</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
</div>
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"flex items-start gap-3 mt-2"}>
|
||||
<Input
|
||||
value={config.managementUrl}
|
||||
onChange={(e) =>
|
||||
setField("managementUrl", e.target.value)
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={"https://netbird.selfhosted.com:443"}
|
||||
error={
|
||||
showError
|
||||
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!canSave}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
onClick={() => saveNow()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
</>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const SectionGroup = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<section className={"mb-8"}>
|
||||
<h2
|
||||
className={
|
||||
"text-xs uppercase tracking-wider text-nb-gray-400 mb-3 font-semibold"
|
||||
}
|
||||
>
|
||||
export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => (
|
||||
<section className={"mb-8 px-1"}>
|
||||
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className={"flex flex-col gap-4"}>{children}</div>
|
||||
<div className={"flex flex-col gap-5"}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
SelfHosted = "selfhosted",
|
||||
}
|
||||
|
||||
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
|
||||
|
||||
function normalizeManagementUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const URL_PATTERN = new RegExp(
|
||||
"^(https?:\\/\\/)?" +
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" +
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" +
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i",
|
||||
);
|
||||
|
||||
function isValidManagementUrl(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
return URL_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
function modeFromUrl(url: string): ManagementMode {
|
||||
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
|
||||
}
|
||||
|
||||
export function useManagementUrl() {
|
||||
const { config, saveField } = useSettings();
|
||||
const [mode, setModeState] = useState<ManagementMode>(
|
||||
modeFromUrl(config.managementUrl),
|
||||
);
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
if (config.managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
setUrl(config.managementUrl);
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
const setMode = (next: ManagementMode) => {
|
||||
setModeState(next);
|
||||
if (
|
||||
next === ManagementMode.Cloud &&
|
||||
config.managementUrl !== CLOUD_MANAGEMENT_URL
|
||||
) {
|
||||
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedUrl = normalizeManagementUrl(url);
|
||||
const urlValid = isValidManagementUrl(url);
|
||||
const targetUrl =
|
||||
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
|
||||
const dirty = targetUrl !== config.managementUrl;
|
||||
const showError =
|
||||
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = () => saveField("managementUrl", targetUrl);
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
url,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user