Merge branch 'ui-refactor' into ui-refactor-ui

This commit is contained in:
Eduard Gert
2026-05-11 15:15:11 +02:00
641 changed files with 28033 additions and 11804 deletions

View File

@@ -0,0 +1,167 @@
import { cva, VariantProps } from "class-variance-authority";
import classNames from "classnames";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
export type ButtonVariants = VariantProps<typeof buttonVariants>;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
}
export const buttonVariants = cva(
[
"relative",
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm",
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
],
{
variants: {
variant: {
default: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"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-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: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
],
subtle: [
"bg-nb-gray-50 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60 border-nb-gray-200 text-nb-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
"dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:border-nb-gray-200 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
],
input: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
],
dropdown: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
],
dotted: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
],
tertiary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
],
white: [
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
],
outline: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
],
"danger-outline": [
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
],
ghost: [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30",
],
danger: [
"dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100",
],
},
size: {
xs: "text-xs py-2 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",
lg: "text-lg py-[9px] px-4",
},
rounded: {
true: "rounded-md",
false: "",
},
border: {
0: "border",
1: "border border-transparent",
2: "border border-t-0 border-b-0",
},
},
},
);
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
variant = "default",
rounded = true,
border = 1,
size = "md",
stopPropagation = true,
type = "button",
children,
className,
onClick,
disabled,
copy,
...props
},
ref,
) {
const [copied, setCopied] = useState(false);
const iconSize = size === "xs" ? 12 : 14;
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();
if (copy !== undefined) {
void navigator.clipboard
.writeText(copy)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}
onClick?.(e);
}}
{...props}
>
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
{children}
</button>
);
});
export default Button;

View File

@@ -0,0 +1,14 @@
import { HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
className,
)}
{...rest}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { ComponentType, forwardRef } from "react";
import { motion, HTMLMotionProps } from "framer-motion";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = HTMLMotionProps<"button"> & {
icon: ComponentType<LucideProps>;
title: string;
description?: string;
active?: boolean;
iconSize?: number;
};
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
function CardNavItem(
{
icon: Icon,
title,
description,
active = false,
iconSize = 15,
className,
type = "button",
...props
},
ref,
) {
return (
<motion.button
ref={ref}
type={type}
whileTap={{ scale: 0.98 }}
className={cn(
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
className,
)}
{...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",
)}
>
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
</div>
<div className={"min-w-0"}>
<h2
className={cn(
"font-medium text-[0.81rem] truncate",
active ? "text-nb-gray-100" : "text-nb-gray-200",
)}
>
{title}
</h2>
{description && (
<p
className={cn(
"text-xs font-medium truncate",
active ? "text-nb-gray-300" : "text-nb-gray-400",
)}
>
{description}
</p>
)}
</div>
</motion.button>
);
},
);

View File

@@ -0,0 +1,149 @@
import {
forwardRef,
ComponentPropsWithoutRef,
ElementRef,
HTMLAttributes,
} from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
export const Trigger = DialogPrimitive.Trigger;
export const Close = DialogPrimitive.Close;
export const Portal = DialogPrimitive.Portal;
export const Overlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 grid place-items-start overflow-y-auto py-16",
"bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
);
});
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
maxWidthClass?: string;
};
export const Content = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ContentProps
>(function DialogContent(
{
className,
children,
showClose = true,
maxWidthClass = "max-w-md",
...props
},
ref,
) {
return (
<DialogPrimitive.Portal>
<Overlay>
<DialogPrimitive.Content
ref={ref}
className={cn(
"mx-auto relative z-[52] w-full outline-none ring-0",
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
"border border-nb-gray-900 bg-nb-gray py-6 shadow-2xl rounded-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
"duration-150 ease-out",
maxWidthClass,
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 z-10 rounded-sm opacity-70 transition-opacity",
"hover:opacity-100 focus:outline-none disabled:pointer-events-none",
"text-nb-gray-300",
)}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</Overlay>
</DialogPrimitive.Portal>
);
});
export const Title = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function DialogTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-md font-semibold leading-none tracking-tight text-nb-gray-50",
className,
)}
{...props}
/>
);
});
export const Description = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function DialogDescription({ className, ...props }, ref) {
return (
<DialogPrimitive.Description
ref={ref}
className={cn(
"text-sm text-nb-gray-400 mt-2 leading-snug",
className,
)}
{...props}
/>
);
});
type FooterProps = HTMLAttributes<HTMLDivElement> & {
separator?: boolean;
};
export const Footer = ({
className,
separator = true,
...props
}: FooterProps) => (
<div className={cn(separator && "border-t border-nb-gray-900 mt-6")}>
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3",
"px-8 pt-6",
className,
)}
{...props}
/>
</div>
);

View File

@@ -0,0 +1,75 @@
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-lg",
}: 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-default transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
disabled && "opacity-30 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

@@ -0,0 +1,22 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
type Props = {
children?: ReactNode;
margin?: boolean;
className?: string;
};
export const HelpText = ({ children, margin = true, className }: Props) => (
<span
className={cn(
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
margin && "mb-2",
className,
)}
>
{children}
</span>
);
export default HelpText;

View File

@@ -0,0 +1,41 @@
import { ComponentType, forwardRef } from "react";
import { motion, HTMLMotionProps } from "framer-motion";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = HTMLMotionProps<"button"> & {
icon: ComponentType<LucideProps>;
iconSize?: number;
iconClassName?: string;
};
export const IconButton = forwardRef<HTMLButtonElement, Props>(
function IconButton(
{
icon: Icon,
iconSize = 18,
iconClassName,
className,
type = "button",
...props
},
ref,
) {
return (
<motion.button
ref={ref}
type={type}
whileTap={{ scale: 0.95 }}
className={cn(
"h-11 w-11 flex items-center justify-center rounded-md cursor-default outline-none",
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-930",
"transition-colors duration-150",
className,
)}
{...props}
>
<Icon size={iconSize} className={iconClassName} />
</motion.button>
);
},
);

View File

@@ -0,0 +1,250 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Label } from "@/components/Label";
type InputVariants = VariantProps<typeof inputVariants>;
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
label?: string;
customPrefix?: ReactNode;
customSuffix?: ReactNode;
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
}
const inputVariants = cva("", {
variants: {
variant: {
default: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
darker: [
"dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-800",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
error: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
],
},
prefixSuffixVariant: {
default: [
"dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300",
],
error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"],
},
},
});
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
type,
label,
customSuffix,
customPrefix,
icon,
maxWidthClass = "",
error,
variant = "default",
prefixClassName,
showPasswordToggle = false,
copy = false,
id,
...props
},
ref,
) {
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPassword ? "text" : type;
const isNumber = type === "number";
const reactId = useId();
const inputId = id ?? (label ? `input-${reactId}` : undefined);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
const stepAttr = el.step !== "" ? Number(el.step) : 1;
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min !== "" ? Number(el.min) : -Infinity;
const max = el.max !== "" ? Number(el.max) : Infinity;
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
const passwordToggle =
isPasswordType && showPasswordToggle ? (
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="hover:text-white transition-all pointer-events-auto"
aria-label="Toggle password visibility"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
) : null;
const onCopy = async () => {
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
const copyToggle = copy ? (
<button
type="button"
onClick={onCopy}
className="hover:text-white transition-all pointer-events-auto"
aria-label="Copy"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
) : null;
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
return (
<div className="flex flex-col w-full min-w-0">
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<div
className={cn(
inputVariants({
prefixSuffixVariant: error ? "error" : "default",
}),
"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,
)}
>
{customPrefix}
</div>
)}
{icon && (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
props.disabled && "opacity-40",
)}
>
{icon}
</div>
)}
<div className="relative flex flex-grow min-w-0">
<input
id={inputId}
type={inputType}
ref={setRefs}
{...props}
className={cn(
inputVariants({
variant: error ? "error" : variant,
}),
"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",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-9",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,
)}
/>
{suffix && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
props.disabled && "opacity-30",
)}
>
{suffix}
</div>
)}
</div>
{showStepper && (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
props.disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label="Increase"
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label="Decrease"
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
)}
</div>
{error && (
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
{error}
</span>
)}
</div>
);
});
export default Input;

View File

@@ -0,0 +1,40 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
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-100 flex items-center gap-2",
);
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & {
as?: "label" | "div";
};
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
{ className, as = "label", children, ...props },
ref,
) {
const classes = cn(labelVariants(), className, "select-none");
if (as === "div") {
return (
<div ref={ref as Ref<HTMLDivElement>} className={classes}>
{children}
</div>
);
}
return (
<LabelPrimitive.Root
ref={ref as Ref<HTMLLabelElement>}
className={classes}
{...props}
>
{children}
</LabelPrimitive.Root>
);
});
export default Label;

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/cn";
import netbirdLogo from "@/assets/logos/netbird.svg";
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
type StateProps = {
state: ConnectionState;
};
type NetBirdConnectToggleProps = {
state: ConnectionState;
size?: number;
onClick?: () => void;
};
export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConnectToggleProps) => {
const [visualState, setVisualState] = useState(state);
useEffect(() => {
setVisualState(state);
}, [state]);
const handleClick = () => {
if (visualState === ConnectionState.Connected) {
setVisualState(ConnectionState.Disconnecting);
} else {
setVisualState(ConnectionState.Connecting);
}
onClick?.();
};
const padding = size * 0.075;
const borderGap = 2;
const borderInset = padding - borderGap;
const innerSize = size * 0.7;
const logoSize = size * 0.26;
const pingInset = size * 0.075;
return (
<div>
<motion.button
className="rounded-full relative overflow-visible cursor-default outline-none border-none bg-transparent"
style={{ padding }}
onClick={handleClick}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<OuterRing state={visualState} />
<BorderInnerRing state={visualState} inset={borderInset} />
<InnerRing size={innerSize}>
<NetBirdLogo state={visualState} logoSize={logoSize} />
<PingRing state={visualState} inset={pingInset} />
</InnerRing>
</motion.button>
</div>
);
};
const OuterRing = ({ state }: StateProps) => {
const isActive = state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
return (
<div
className={cn(
"absolute inset-0 rounded-full transition-all",
isActive ? "bg-netbird-500/20" : "bg-neutral-700",
state === ConnectionState.Disconnecting && "animate-pulse-slow",
)}
/>
);
};
const BorderInnerRing = ({ state, inset }: StateProps & { inset: number }) => (
<div
className={cn(
"absolute rounded-full transition-all duration-1000",
state === ConnectionState.Connected && "bg-netbird-600",
state === ConnectionState.Disconnecting && "bg-conic-netbird animate-spin-slow",
state !== ConnectionState.Connected && state !== ConnectionState.Disconnecting && "bg-neutral-500",
)}
style={{ inset }}
/>
);
const InnerRing = ({ children, size }: { children: React.ReactNode; size: number }) => (
<div
className="rounded-full bg-nb-gray flex items-center justify-center relative z-10 mx-auto"
style={{ width: size, height: size }}
>
{children}
</div>
);
const NetBirdLogo = ({ state, logoSize }: StateProps & { logoSize: number }) => {
const isConnecting = state === ConnectionState.Connecting;
return (
<div
className={cn(isConnecting && "animate-pulse-slow")}
style={isConnecting ? { animationDelay: "0.1s" } : undefined}
>
<img
src={netbirdLogo}
alt="NetBird"
width={logoSize}
className={cn(
"filter transition-all duration-1000",
state === ConnectionState.Disconnected ? "grayscale" : "grayscale-0",
)}
/>
</div>
);
};
const PingRing = ({ state, inset }: StateProps & { inset: number }) => (
<span
className={cn(
"block absolute border-2 border-netbird rounded-full",
state === ConnectionState.Connecting ? "animate-ping-slow" : "hidden",
)}
style={{ inset }}
/>
);

View File

@@ -0,0 +1,91 @@
import { ReactNode } from "react";
import { Browser } from "@wailsio/runtime";
import { Update as UpdateSvc } from "@bindings/services";
import { Button } from "@/components/Button";
import { useStatus } from "@/hooks/useStatus";
import { cn } from "@/lib/cn";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
}
function formatLastChecked(date: Date) {
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function triggerUpdate() {
UpdateSvc.Trigger().catch(() => {});
}
export function NetBirdVersionCard() {
const { status } = useStatus();
const updateVersion = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
if (updateVersion) {
return (
<Card>
<div>
<Title>Version {updateVersion} is available.</Title>
<Link
url={`https://github.com/netbirdio/netbird/releases/tag/v${updateVersion}`}
>
What's new?
</Link>
</div>
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
Restart Now
</Button>
</Card>
);
}
return (
<Card className={"max-w-md"}>
<div>
<Title>Last checked on {formatLastChecked(new Date())}</Title>
<Link url={"https://github.com/netbirdio/netbird/releases/latest"}>Changelog</Link>
</div>
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
Check for updates
</Button>
</Card>
);
}
function Card({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn(
"w-full max-w-md flex items-center justify-between gap-4 rounded-md border border-nb-gray-800 bg-nb-gray-910 px-4 py-3",
className,
)}
>
{children}
</div>
);
}
function Title({ children }: { children: ReactNode }) {
return <p className={"text-sm font-semibold"}>{children}</p>;
}
function Link({ url, children }: { url: string; children: ReactNode }) {
return (
<button
type={"button"}
onClick={() => openUrl(url)}
className={
"text-sm text-netbird hover:underline hover:underline-offset-4 hover:decoration-[0.5px] font-medium"
}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,73 @@
import { FormEvent, useEffect, useState } from "react";
import * as Dialog from "@/components/Dialog";
import { Input } from "@/components/Input";
import { Button } from "@/components/Button";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string) => void;
};
export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
const [name, setName] = useState("");
useEffect(() => {
if (!open) setName("");
}, [open]);
const trimmed = name.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
onCreate(trimmed);
onOpenChange(false);
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidthClass="max-w-md"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<form onSubmit={handleSubmit}>
<div className="px-8 pt-2">
<Dialog.Title>New Profile</Dialog.Title>
<Dialog.Description>
Profiles let you keep separate NetBird connections
side by side. Give your profile a memorable name.
</Dialog.Description>
</div>
<div className="px-8 pt-3">
<Input
autoFocus
placeholder="e.g. Work"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<Dialog.Footer>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSubmit}
>
Create
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,359 @@
import { useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Dialogs } from "@wailsio/runtime";
import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react";
import { cn } from "@/lib/cn";
import { generateColorFromString } from "@/lib/color";
import { NewProfileDialog } from "@/components/NewProfileDialog";
export type Profile = {
id: string;
name: string;
};
const MOCK_PROFILES: Profile[] = [
{ id: "default", name: "Default Profile" },
{ id: "work", name: "Work" },
{ id: "personal", name: "Personal" },
{ id: "staging", name: "Staging" },
{ id: "production", name: "Production" },
{ id: "dev", name: "Development" },
{ id: "qa", name: "QA Environment" },
{ id: "demo", name: "Demo" },
{ id: "client-acme", name: "Client - ACME" },
{ id: "client-globex", name: "Client - Globex" },
{ id: "client-initech", name: "Client - Initech" },
{ id: "homelab", name: "Homelab" },
{ id: "office-berlin", name: "Office Berlin" },
{ id: "office-sf", name: "Office San Francisco" },
{ id: "office-tokyo", name: "Office Tokyo" },
{ id: "vpn-eu", name: "VPN EU" },
{ id: "vpn-us", name: "VPN US" },
{ id: "vpn-asia", name: "VPN Asia" },
{ id: "test", name: "Test" },
{ id: "sandbox", name: "Sandbox" },
];
type Props = {
email?: string;
};
export const ProfileSelector = ({ email = "" }: Props) => {
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
const [selectedId, setSelectedId] = useState<string>(MOCK_PROFILES[0].id);
const [open, setOpen] = useState(false);
const [newOpen, setNewOpen] = useState(false);
const selected = profiles.find((p) => p.id === selectedId) ?? profiles[0];
const sorted = [...profiles].sort((a, b) => a.name.localeCompare(b.name));
const handleSelect = (id: string) => {
setSelectedId(id);
setOpen(false);
};
const handleDeregister = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
const result = await Dialogs.Warning({
Title: "Deregister Profile",
Message: `Are you sure you want to deregister "${profile.name}"? You will need to log in again to use it.`,
Buttons: [
{ Label: "Cancel", IsCancel: true },
{ Label: "Deregister", IsDefault: true },
],
});
if (result !== "Deregister") return;
console.log("Deregister profile", id);
};
const handleDelete = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
const result = await Dialogs.Warning({
Title: "Delete Profile",
Message: `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`,
Buttons: [
{ Label: "Cancel", IsCancel: true },
{ Label: "Delete", IsDefault: true },
],
});
if (result !== "Delete") return;
setProfiles((prev) => prev.filter((p) => p.id !== id));
if (selectedId === id) {
const remaining = profiles.filter((p) => p.id !== id);
if (remaining.length > 0) setSelectedId(remaining[0].id);
}
};
const handleNewProfile = () => {
setOpen(false);
setNewOpen(true);
};
const handleCreateProfile = (name: string) => {
const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`;
setProfiles((prev) => [...prev, { id, name }]);
setSelectedId(id);
};
const initial = selected?.name.charAt(0).toUpperCase() ?? "?";
const initialColor = generateColorFromString(selected?.name);
return (
<>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className={
"h-11 rounded-md text-nb-gray-300 flex items-center gap-1 text-xs hover:bg-nb-gray-930 data-[state=open]:bg-nb-gray-930 px-2 -mx-1 outline-none cursor-default transition-colors duration-150"
}
>
<div
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}
</div>
<div
className={cn(
"whitespace-nowrap flex flex-col ml-1 text-left",
email ? "mt-1" : "justify-center",
)}
>
<span className={"leading-none text-nb-gray-200 font-semibold"}>
{selected?.name ?? "No profile"}
</span>
{email && (
<span className={"text-[0.73rem] font-normal text-nb-gray-300"}>
{email}
</span>
)}
</div>
<ChevronDown size={14} className={"ml-2 mr-2"} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
sideOffset={6}
className={cn(
"w-72 rounded-md border border-nb-gray-900 bg-nb-gray-930 shadow-lg",
"p-1 z-50 origin-[var(--radix-popover-content-transform-origin)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=top]:slide-in-from-bottom-1",
"duration-150 ease-out",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Command
loop
className={cn(
"flex flex-col",
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
)}
>
<div className="px-1 pb-1">
<div className="group flex items-center gap-2 px-2 h-8">
<Search size={12} className="text-nb-gray-300 shrink-0" />
<Command.Input
autoFocus
placeholder="Search profile by name..."
className={cn(
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
)}
/>
</div>
</div>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-64 px-1 pb-1">
<Command.List>
<Command.Empty>
<div className="flex flex-col items-center text-center px-4 pt-2 pb-3">
<h3 className="text-xs font-semibold text-nb-gray-200">
No Profiles Found
</h3>
<p className="text-[0.7rem] leading-snug text-nb-gray-400 mt-1 text-balance">
Try a different search term or create a new
profile.
</p>
</div>
</Command.Empty>
{sorted.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
selected={profile.id === selectedId}
onSelect={() => handleSelect(profile.id)}
onDeregister={() => handleDeregister(profile.id)}
onDelete={() => handleDelete(profile.id)}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="h-px bg-nb-gray-920 -mx-1 my-1" />
<button
type="button"
onClick={handleNewProfile}
className={cn(
"w-full flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
"text-netbird hover:bg-nb-gray-910",
)}
>
<div
className={
"h-6 w-6 flex items-center justify-center rounded-md bg-nb-gray-900 shrink-0"
}
>
<PlusCircle size={12} className="text-netbird" />
</div>
<span className="text-xs font-semibold">New Profile</span>
</button>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<NewProfileDialog
open={newOpen}
onOpenChange={setNewOpen}
onCreate={handleCreateProfile}
/>
</>
);
};
type ProfileRowProps = {
profile: Profile;
selected: boolean;
onSelect: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: ProfileRowProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const initial = profile.name.charAt(0).toUpperCase();
const initialColor = generateColorFromString(profile.name);
return (
<Command.Item
value={profile.name}
keywords={[profile.id]}
onSelect={() => onSelect()}
className={cn(
"group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
"data-[selected=true]:bg-nb-gray-910",
selected && "bg-nb-gray-910",
)}
>
<div
className={cn(
"h-6 w-6 flex items-center justify-center rounded-md text-[0.65rem] font-semibold shrink-0 bg-nb-gray-900",
"group-data-[selected=true]:bg-nb-gray-850",
selected && "bg-nb-gray-850",
)}
style={{ color: initialColor }}
>
{initial}
</div>
<span
className={cn(
"flex-1 truncate text-xs",
selected ? "text-nb-gray-200 font-semibold" : "text-nb-gray-200",
)}
>
{profile.name}
</span>
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenu.Trigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
"h-6 w-6 flex items-center justify-center rounded text-nb-gray-400 cursor-default",
"hover:bg-nb-gray-800 hover:text-nb-gray-200 outline-none",
"data-[state=open]:bg-nb-gray-800 data-[state=open]:text-nb-gray-200",
)}
aria-label="More options"
>
<MoreVertical size={14} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="end"
sideOffset={4}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className={cn(
"w-44 rounded-md border border-nb-gray-850 bg-nb-gray-910 shadow-lg p-1 z-50",
)}
>
<DropdownMenu.Item
onSelect={(e) => {
e.preventDefault();
onDeregister();
setMenuOpen(false);
}}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
"text-xs text-nb-gray-200 data-[highlighted]:bg-nb-gray-850",
)}
>
<UserMinus size={14} className="text-nb-gray-300" />
<span>Deregister</span>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={(e) => {
e.preventDefault();
onDelete();
setMenuOpen(false);
}}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
"text-xs text-red-500 data-[highlighted]:bg-nb-gray-850",
)}
>
<Trash2 size={14} />
<span>Delete Profile</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</Command.Item>
);
};

View File

@@ -0,0 +1,30 @@
import { forwardRef, InputHTMLAttributes } from "react";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = InputHTMLAttributes<HTMLInputElement> & {
iconSize?: number;
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(
function SearchInput({ iconSize = 14, className, ...props }, ref) {
return (
<div className={"flex items-center gap-2 px-2 h-9"}>
<SearchIcon
size={iconSize}
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
{...props}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
className,
)}
/>
</div>
);
},
);

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { Check, Loader2, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "loading" | "success" | "error";
type Props = {
variant: Variant;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
};
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
loading: {
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
className: "bg-nb-gray-100",
},
success: {
icon: <Check className={"text-white"} size={18} />,
className: "bg-green-500",
},
error: {
icon: <XCircle className={"text-white"} size={18} />,
className: "bg-red-500",
},
};
export function StatusPanel({ variant, title, description, children, actions }: Props) {
const { icon, className } = VARIANTS[variant];
return (
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
{icon}
</div>
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
</div>
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { cn } from "../lib/cn";
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
description?: string;
}
export function Switch({ checked, onChange, disabled, label, description }: Props) {
return (
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
{(label || description) && (
<span className="flex flex-col">
{label && <span className="text-sm font-medium">{label}</span>}
{description && (
<span className="text-xs text-nb-gray-500">{description}</span>
)}
</span>
)}
</label>
);
}

View File

@@ -0,0 +1,39 @@
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-700"}
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<span className={"relative inline-flex items-center justify-center gap-1"}>
{children}
</span>
</RadioGroup.Item>
);
};

View File

@@ -0,0 +1,44 @@
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-910 p-1",
className,
)}
>
{children}
</RadioGroup.Root>
</SwitchItemGroupContext.Provider>
);
};

View File

@@ -0,0 +1,40 @@
import { ReactNode, useState } from "react";
import { cn } from "../lib/cn";
interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface Props {
tabs: Tab[];
initial?: string;
}
export function Tabs({ tabs, initial }: Props) {
const [active, setActive] = useState(initial ?? tabs[0]?.value);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setActive(t.value)}
className={cn(
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active === t.value
? "border-netbird text-netbird"
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
)}
>
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto">
{tabs.find((t) => t.value === active)?.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"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",
"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": {
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-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 }),
)}
{...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

@@ -0,0 +1,87 @@
import { ComponentType, forwardRef } from "react";
import * as Tabs from "@radix-ui/react-tabs";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
const Root = forwardRef<
HTMLDivElement,
Omit<Tabs.TabsProps, "orientation">
>(function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.Root
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0 gap-4", className)}
{...props}
/>
);
});
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
function VerticalTabsList({ className, ...props }, ref) {
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1", className)}
{...props}
/>
);
},
);
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
iconSize?: number;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
</Tabs.Trigger>
);
},
);
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
function VerticalTabsContent({ className, ...props }, ref) {
return (
<Tabs.Content
ref={ref}
className={cn("outline-none", className)}
{...props}
/>
);
},
);
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });