This commit is contained in:
Eduard Gert
2026-05-07 09:57:14 +02:00
parent 553be144b4
commit debb558aa3
22 changed files with 774 additions and 178 deletions

View File

@@ -5,35 +5,30 @@ import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import QuickActions from "@/screens/QuickActions.tsx";
import LoginUrl from "@/screens/LoginUrl.tsx";
import Update from "@/screens/Update.tsx";
import Layout from "@/layout.tsx";
import Peers from "@/screens/Peers.tsx";
import Networks from "@/screens/Networks.tsx";
import Profiles from "@/screens/Profiles.tsx";
import Settings from "@/screens/Settings.tsx";
import Debug from "@/screens/Debug.tsx";
import {Main} from "@/screens/Main.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
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";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<Layout />}>
<Route index element={<Main />} />
<Route path="peers" element={<Peers />} />
<Route path="networks" element={<Networks />} />
<Route path="profiles" element={<Profiles />} />
<Route path="settings" element={<Settings />} />
<Route path="debug" element={<Debug />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
<React.StrictMode>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<AppLayout />}>
<Route index element={<Main />} />
<Route path="settings" element={<Settings />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}
/>
</Route>
</Routes>
</HashRouter>
</SkeletonTheme>
</React.StrictMode>,
);

View File

@@ -0,0 +1,79 @@
import React from "react";
import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { ToggleSwitch } from "@/components/ToggleSwitch";
import { cn } from "@/lib/cn";
interface Props {
value: boolean;
onChange: (value: boolean) => void;
helpText?: React.ReactNode;
label?: React.ReactNode;
children?: React.ReactNode;
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
value,
onChange,
helpText,
label,
children,
disabled = false,
dataCy,
className,
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
}
};
return (
<div
onClick={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
className={cn(
"cursor-pointer transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
disabled && "opacity-50 pointer-events-none",
className,
)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch
checked={value}
onCheckedChange={onChange}
dataCy={dataCy}
/>
</div>
</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}
</div>
);
}

View File

@@ -9,6 +9,7 @@ type Props = HTMLMotionProps<"button"> & {
description?: string;
active?: boolean;
iconSize?: number;
iconBackground?: boolean;
};
export const NavItem = forwardRef<HTMLButtonElement, Props>(
@@ -19,6 +20,7 @@ export const NavItem = forwardRef<HTMLButtonElement, Props>(
description,
active = false,
iconSize = 15,
iconBackground = true,
className,
type = "button",
...props
@@ -40,21 +42,33 @@ export const NavItem = forwardRef<HTMLButtonElement, Props>(
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
{iconBackground ? (
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active
? "text-nb-gray-200"
: "text-nb-gray-400",
)}
/>
</div>
) : (
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
"shrink-0 ml-2 transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
</div>
)}
<div className={"min-w-0"}>
<h2
className={cn(

View File

@@ -1,7 +0,0 @@
export default function PlaceholderHeader() {
return (
<div
className="h-[36px] shrink-0 cursor-default wails-draggable"
/>
);
}

View File

@@ -124,9 +124,10 @@ export const ProfileSelector = ({ email = "" }: Props) => {
}
>
<div
className={
"h-7 w-7 flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold"
}
className={cn(
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
email ? "h-7 w-7" : "h-6 w-6",
)}
style={{ color: initialColor }}
>
{initial}

View File

@@ -0,0 +1,71 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/cn";
type SwitchVariants = VariantProps<typeof switchVariants>;
const switchVariants = cva("", {
variants: {
size: {
default: "h-[24px] w-[44px]",
small: "h-[18px] w-[36px]",
},
variant: {
default: [
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
],
"red-green": [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
],
red: [
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
"data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
],
},
"thumb-size": {
default: "h-5 w-5 data-[state=checked]:translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
},
},
});
const ToggleSwitch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
SwitchVariants & { dataCy?: string }
>(
(
{ className, size = "default", variant = "default", dataCy, ...props },
ref,
) => (
<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",
className,
switchVariants({ size, variant }),
)}
{...props}
data-cy={dataCy}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
switchVariants({ "thumb-size": size }),
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
)}
/>
</SwitchPrimitives.Root>
),
);
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
export { ToggleSwitch };

View File

@@ -1,15 +0,0 @@
import { Outlet } from "react-router-dom";
import PlaceholderHeader from "@/components/PlaceholderHeader";
export default function Layout() {
return (
<div className="flex h-full flex-col">
<PlaceholderHeader />
<div className="flex min-h-0 flex-1">
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx";
export const AppLayout = () => {
return (
<div className={"flex h-full flex-col"}>
<Header />
<Outlet />
</div>
);
};

View File

@@ -5,7 +5,7 @@ import {
export const ConnectionStatus = () => {
return (
<div className={"flex flex-col items-center"}>
<div className={"flex flex-col h-full items-center justify-center"}>
<NetBirdConnectToggle state={ConnectionState.Connected} />
<h1
className={

View File

@@ -1,20 +1,26 @@
import { useLocation, useNavigate } from "react-router-dom";
import { SettingsIcon } from "lucide-react";
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
import { IconButton } from "@/components/IconButton.tsx";
import { SettingsIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = {
settingsActive?: boolean;
onSettingsClick?: () => void;
};
export const Header = () => {
const navigate = useNavigate();
const location = useLocation();
const settingsActive = location.pathname.startsWith("/settings");
export const Header = ({ settingsActive = false, onSettingsClick }: Props) => {
return (
<div className={"w-full justify-between flex mb-12"}>
<ProfileSelector email={"eduard@netbird.io"} />
<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"
}
>
<div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} />
</div>
<IconButton
icon={SettingsIcon}
onClick={onSettingsClick}
onClick={() => navigate(settingsActive ? "/" : "/settings")}
className={cn(
settingsActive &&
"bg-nb-gray-930 text-nb-gray-200 hover:text-nb-gray-200",

View File

@@ -0,0 +1,22 @@
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
import { Peers } from "@/modules/peers/Peers.tsx";
export const Main = () => {
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"
}
>
<ConnectionStatus />
<Navigation peersActive />
</div>
<MainRightSide>
<Peers />
</MainRightSide>
</div>
);
};

View File

@@ -1,32 +0,0 @@
import { ConnectionStatus } from "@/layouts/ConnectionStatus.tsx";
import { Header } from "@/layouts/Header.tsx";
import { Navigation } from "@/layouts/Navigation.tsx";
export type MainModule = "peers" | "settings";
type Props = {
active: MainModule;
onChange: (module: MainModule) => void;
};
export const MainLeftSide = ({ active, onChange }: Props) => {
return (
<div
className={"flex flex-col max-w-xs w-full shrink-0 items-center"}
>
<Header
settingsActive={active === "settings"}
onSettingsClick={() =>
onChange(active === "settings" ? "peers" : "settings")
}
/>
<ConnectionStatus />
<Navigation
peersActive={active === "peers"}
onPeersClick={() => {
if (active !== "peers") onChange("peers");
}}
/>
</div>
);
};

View File

@@ -12,7 +12,7 @@ type Props = {
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1 mt-8"}>
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
<NavItem
icon={MonitorSmartphoneIcon}
title={"Peers"}

View File

@@ -0,0 +1,27 @@
import { createContext, ReactNode, useContext, useState } from "react";
export type MainModule = "peers" | "settings";
type Ctx = {
active: MainModule;
setActive: (m: MainModule) => void;
};
const MainModuleContext = createContext<Ctx | null>(null);
export const MainModuleProvider = ({ children }: { children: ReactNode }) => {
const [active, setActive] = useState<MainModule>("peers");
return (
<MainModuleContext.Provider value={{ active, setActive }}>
{children}
</MainModuleContext.Provider>
);
};
export const useMainModule = () => {
const ctx = useContext(MainModuleContext);
if (!ctx) {
throw new Error("useMainModule must be used within MainModuleProvider");
}
return ctx;
};

View File

@@ -1,7 +1,19 @@
import { useState } from "react";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import {
SettingsNavigation,
SettingsSection,
} from "@/modules/settings/SettingsNavigation.tsx";
export const Settings = () => {
const [active, setActive] = useState<SettingsSection>("general");
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4 px-4"}>
<h2 className={"text-sm font-medium text-nb-gray-200"}>Settings</h2>
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
<div className={"flex flex-col w-52 shrink-0 items-center"}>
<SettingsNavigation active={active} onChange={setActive} />
</div>
<MainRightSide>{null}</MainRightSide>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { NavItem } from "@/components/NavItem.tsx";
import {
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
SlidersHorizontalIcon,
TerminalIcon,
} from "lucide-react";
export type SettingsSection =
| "general"
| "network"
| "ssh"
| "troubleshooting"
| "about";
type Props = {
active: SettingsSection;
onChange: (section: SettingsSection) => void;
};
const ITEMS: {
id: SettingsSection;
icon: typeof SlidersHorizontalIcon;
title: string;
}[] = [
{ id: "general", icon: SlidersHorizontalIcon, title: "General" },
{ id: "network", icon: NetworkIcon, title: "Network" },
{ id: "ssh", icon: TerminalIcon, title: "SSH" },
{ id: "troubleshooting", icon: LifeBuoyIcon, title: "Troubleshooting" },
{ id: "about", icon: InfoIcon, title: "About" },
];
export const SettingsNavigation = ({ active, onChange }: Props) => {
return (
<nav className={"w-full flex flex-col gap-1"}>
{ITEMS.map(({ id, icon, title }) => (
<NavItem
key={id}
icon={icon}
title={title}
iconSize={14}
iconBackground={false}
className={"py-2.5"}
active={active === id}
onClick={() => {
if (active !== id) onChange(id);
}}
/>
))}
</nav>
);
};

View File

@@ -1,23 +0,0 @@
import { useState } from "react";
import { MainLeftSide, MainModule } from "@/layouts/MainLeftSide.tsx";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { Peers } from "@/modules/peers/Peers.tsx";
import { Settings } from "@/modules/settings/Settings.tsx";
type Props = {
};
export const Main = ({}: Props) => {
const [active, setActive] = useState<MainModule>("peers");
return (
<div className={"wails-draggable flex h-full p-4 gap-4 min-h-0"}>
<MainLeftSide active={active} onChange={setActive} />
<MainRightSide>
{active === "peers" ? <Peers /> : <Settings />}
</MainRightSide>
</div>
);
};