[client] UI refactor (#6069)

Refactor UI

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Zoltan Papp
2026-06-19 09:59:28 +02:00
committed by GitHub
parent 679c7182a4
commit 8b7ce337d8
394 changed files with 46607 additions and 6687 deletions

View File

@@ -0,0 +1,43 @@
import { forwardRef, type ComponentType, type HTMLAttributes } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
type Props = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
icon?: ComponentType<LucideProps>;
iconSize?: number;
};
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
info: "bg-sky-900 border border-sky-700 text-sky-200",
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
success: "bg-green-900 border border-green-700 text-green-200",
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
danger: "bg-red-900 border border-red-700 text-red-200",
};
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
ref,
) {
return (
<span
ref={ref}
className={cn(
"relative top-px inline-flex items-center gap-1 rounded-full px-1.5 py-[0.15rem]",
"shrink-0 text-[0.64rem] font-semibold leading-none",
VARIANT_CLASSES[variant],
className,
)}
{...rest}
>
{Icon && <Icon size={iconSize} aria-hidden={"true"} />}
{children}
</span>
);
});
export default Badge;

View File

@@ -0,0 +1,126 @@
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
const VARIANT_HOVER = {
default: "group-hover/copy:[&_*]:text-nb-gray-300",
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
} as const;
type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
type CopyToClipboardProps = {
children: ReactNode;
message?: string;
size?: number;
iconAlignment?: "left" | "right";
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
variant?: CopyToClipboardVariant;
"aria-label"?: string;
tabIndex?: number;
onKeyDown?: (e: KeyboardEvent<HTMLButtonElement>) => void;
};
export const CopyToClipboard = ({
children,
message,
size = 10,
iconAlignment = "right",
className,
iconClassName,
alwaysShowIcon = false,
variant = "default",
"aria-label": ariaLabel,
tabIndex = 0,
onKeyDown,
}: CopyToClipboardProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const text = message ?? wrapperRef.current?.innerText ?? "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 500);
} catch (e) {
console.warn("copy to clipboard failed", e);
}
};
const resolvedLabel =
ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
return (
<button
type={"button"}
ref={wrapperRef}
onClick={handleClick}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
aria-label={resolvedLabel}
aria-live={"polite"}
className={cn(
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
>
<span
className={cn(
"relative min-w-0 truncate",
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
>
{children}
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
}
/>
</span>
<span
aria-hidden={"true"}
className={cn(
"relative right-[1px] top-[2px] inline-flex shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
iconClassName,
)}
>
<Check
size={size}
className={cn(
"text-nb-gray-100",
!copied && "hidden",
!alwaysShowIcon && !copied && "opacity-0",
)}
/>
<Copy
size={size}
className={cn(
"text-nb-gray-100 group-hover/copy:opacity-100",
copied && "hidden",
!alwaysShowIcon && "opacity-0",
)}
/>
</span>
</button>
);
};

View File

@@ -0,0 +1,233 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cva } from "class-variance-authority";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuItemVariants = cva("", {
variants: {
variant: {
default:
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
},
},
defaultVariants: { variant: "default" },
});
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
variant?: "default" | "danger";
}
>(({ className, inset, children, variant, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
menuItemVariants({ variant }),
className,
)}
{...props}
>
{children}
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-nb-gray-900 bg-nb-gray-930 p-1 text-nb-gray-200 shadow-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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "danger";
href?: string;
target?: string;
rel?: string;
}
>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
menuItemVariants({ variant }),
className,
)}
onClick={(e) => {
if (href) return;
e.preventDefault();
e.stopPropagation();
onClick?.(e);
}}
{...props}
>
{href ? (
<a href={href} target={target} rel={rel} className={"flex w-full items-center gap-3"}>
{children}
</a>
) : (
children
)}
</DropdownMenuPrimitive.Item>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className={"h-4 w-4"} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className={"h-2 w-2 fill-current"} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-nb-gray-910", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -0,0 +1,235 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { type LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
const labelFor = (lang: Language): string =>
lang.englishName && lang.englishName !== lang.displayName
? `${lang.displayName} (${lang.englishName})`
: lang.displayName;
export function LanguagePicker() {
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const isFocusVisible = useFocusVisible();
useEffect(() => {
let cancelled = false;
loadLanguages()
.then((list) => {
if (!cancelled) setLanguages(list);
})
.catch((err: unknown) => console.error("load languages failed", err));
return () => {
cancelled = true;
};
}, []);
const sorted = useMemo(
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
[languages],
);
const current = useMemo(
() =>
languages.find((l) => l.code === i18n.language) ??
languages.find((l) => l.code === "en"),
[languages, i18n.language],
);
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
}
};
const select = async (code: string) => {
setOpen(false);
if (busy || code === i18n.language) return;
setBusy(true);
try {
await Preferences.SetLanguage(code as LanguageCode);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
};
return (
<div className={"flex items-center justify-between gap-6"}>
<div className={"max-w-md flex-1"}>
<Label as={"div"}>{t("settings.general.language.label")}</Label>
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={busy || languages.length === 0}
onKeyDown={handleTriggerKeyDown}
aria-label={t("settings.general.language.label")}
aria-haspopup={"listbox"}
aria-expanded={open}
className={cn(
"inline-flex h-[40px] min-w-[240px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
)}
>
<LanguagesIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"start"}
sideOffset={6}
className={cn(
"w-[var(--radix-popover-trigger-width)]",
"z-50 rounded-lg border border-nb-gray-850 bg-nb-gray-920 p-1 shadow-lg",
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=top]:slide-in-from-bottom-1",
"duration-150 ease-out",
)}
>
<Command
loop
className={cn(
"flex flex-col",
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
)}
>
<div className={"px-1 pb-1"}>
<div
role={"search"}
className={"group flex h-8 items-center gap-2 px-1"}
>
<Search
size={14}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<Command.Input
autoFocus
placeholder={t("settings.general.language.search")}
aria-label={t("settings.general.language.search")}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"border-none outline-none",
)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
<div
className={
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
}
>
{t("settings.general.language.empty")}
</div>
</Command.Empty>
{sorted.map((lang) => {
const checked = lang.code === i18n.language;
return (
<Command.Item
key={lang.code}
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
onSelect={() => void select(lang.code)}
className={cn(
"my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none",
"text-xs font-semibold text-nb-gray-200",
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
)}
>
<span className={"min-w-0 flex-1 truncate"}>
{labelFor(lang)}
</span>
<span
aria-hidden={"true"}
className={
"flex w-4 shrink-0 items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</Command.Item>
);
})}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useTranslation } from "react-i18next";
import netbirdLogo from "@/assets/logos/netbird.svg";
import { SwitchItem } from "@/components/switches/SwitchItem";
import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
fullWidth?: boolean;
};
export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
const { t, i18n } = useTranslation();
const itemClass = fullWidth ? "flex-1" : undefined;
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
aria-label={t("settings.general.management.label")}
className={fullWidth ? "w-full" : undefined}
>
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
<img
src={netbirdLogo}
alt={""}
aria-hidden={"true"}
className={"aspect-[31/23] h-[0.8rem] shrink-0"}
/>
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
{t("settings.general.management.selfHosted")}
</SwitchItem>
</SwitchItemGroup>
);
};

View File

@@ -0,0 +1,37 @@
import { type ComponentType } from "react";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
const variantClass: Record<SquareIconVariant, string> = {
default: "text-white",
info: "text-sky-400",
warning: "text-netbird",
danger: "text-red-500",
};
type SquareIconProps = {
icon: ComponentType<LucideProps>;
iconSize?: number;
variant?: SquareIconVariant;
className?: string;
};
export const SquareIcon = ({
icon: Icon,
iconSize = 18,
variant = "default",
className,
}: SquareIconProps) => (
<div
aria-hidden={"true"}
className={cn(
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-900 bg-nb-gray-920",
variantClass[variant],
className,
)}
>
<Icon size={iconSize} />
</div>
);

View File

@@ -0,0 +1,98 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
type Props = {
content: ReactNode;
children: ReactNode;
side?: RTooltip.TooltipContentProps["side"];
align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number;
sideOffset?: number;
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
contentClassName?: string;
closeDelay?: number;
};
export const Tooltip = ({
content,
children,
side = "bottom",
align = "center",
delayDuration = 200,
sideOffset = 6,
alignOffset = 0,
interactive = false,
keepOpenOnClick = true,
contentClassName,
closeDelay = 0,
}: Props) => {
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
if (closeDelay <= 0) {
setOpen(false);
return;
}
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
};
useEffect(() => () => cancelClose(), []);
const handleOpenChange = (next: boolean) => {
if (!next && keepOpenOnClick && hoveringRef.current) return;
if (next) cancelClose();
setOpen(next);
};
return (
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
<RTooltip.Trigger
asChild
onPointerEnter={() => {
hoveringRef.current = true;
cancelClose();
}}
onPointerLeave={() => {
hoveringRef.current = false;
scheduleClose();
}}
>
{children}
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
className={cn(
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
!interactive && "pointer-events-none",
contentClassName ??
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
)}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
</RTooltip.Provider>
);
};

View File

@@ -0,0 +1,32 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Tooltip } from "@/components/Tooltip";
type Props = {
text: string;
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
};
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [text]);
const span = (
<span ref={ref} className={className}>
{text}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={tooltipContent ?? text} delayDuration={delayDuration}>
{span}
</Tooltip>
);
};

View File

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

View File

@@ -0,0 +1,195 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Loader2 } from "lucide-react";
import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
type ButtonVariants = VariantProps<typeof buttonVariants>;
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
loading?: boolean;
}
const buttonVariants = cva(
[
"relative",
"cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2",
"inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300",
],
{
variants: {
variant: {
default: [
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
],
primary: [
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
],
secondary: [
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
],
secondaryLighter: [
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
],
subtle: [
"border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
"dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
],
input: [
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
],
dropdown: [
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
],
dotted: [
"border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
],
tertiary: [
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
],
white: [
"border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
"disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
],
outline: [
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
"dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
],
"danger-outline": [
"dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
],
"danger-text": [
"rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
"data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
],
ghost: [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
],
danger: [
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
],
},
size: {
xs: "px-3.5 py-2.5 text-xs",
xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]",
sm: "px-4 py-[9px] text-sm",
md: "px-4 py-[9px]",
lg: "px-4 py-[9px] text-lg",
},
rounded: {
true: "rounded-md",
false: "",
},
border: {
0: "border",
1: "border border-transparent",
2: "border border-b-0 border-t-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,
loading = false,
...props
},
ref,
) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const iconSize = size === "xs" ? 12 : 14;
return (
<button
ref={ref}
type={type}
tabIndex={0}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
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);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
})
.catch((e: unknown) => console.warn("copy to clipboard failed", e));
}
onClick?.(e);
}}
{...props}
>
{loading && (
<span
aria-hidden={"true"}
className={"absolute inset-0 flex items-center justify-center"}
>
<Loader2 size={iconSize} className={"animate-spin"} />
</span>
)}
<span className={cn("contents", loading && "invisible")}>
{copy !== undefined &&
(copied ? (
<Check size={iconSize} aria-hidden={"true"} />
) : (
<Copy size={iconSize} aria-hidden={"true"} />
))}
{children}
</span>
</button>
);
});
export default Button;

View File

@@ -0,0 +1,36 @@
import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react";
import { type LucideProps } from "lucide-react";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
icon: ComponentType<LucideProps>;
iconSize?: number;
iconClassName?: string;
};
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<button
ref={ref}
type={type}
disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}
{...props}
>
<Icon size={iconSize} className={iconClassName} />
</button>
);
});

View File

@@ -0,0 +1,35 @@
import { type ReactNode, forwardRef } from "react";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type ConfirmDialogProps = {
children: ReactNode;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
ref,
) {
return (
<dialog
open
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={
"wails-draggable static m-0 flex max-h-none w-full max-w-none select-none flex-col items-center border-0 bg-transparent p-0 text-inherit"
}
>
<div
ref={ref}
className={cn(
"flex flex-col items-center gap-5 px-8 pb-7 pt-6 text-center",
isMacOS() && "pt-10",
)}
>
{children}
</div>
</dialog>
);
});

View File

@@ -0,0 +1,84 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Button } from "@/components/buttons/Button";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export const ConfirmModal = ({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
}}
>
<Dialog.Content
maxWidthClass={"max-w-sm"}
showClose={false}
className={"py-5"}
srTitle={srTitle}
srDescription={srDescription}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className={"flex flex-col gap-5 px-5"}>
<div className={"flex flex-col gap-1 pl-1"}>
<DialogHeading align={"left"}>{title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
{description}
</DialogDescription>
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button
variant={"secondary"}
size={"sm"}
disabled={busy}
onClick={onCancel}
>
{resolvedCancel}
</Button>
<Button
autoFocus
variant={danger ? "danger" : "primary"}
size={"sm"}
disabled={busy}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogActions>
</div>
</Dialog.Content>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,159 @@
import {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
type 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 { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
type OverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
exitAnimation?: boolean;
};
const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayProps>(
function DialogOverlay({ className, exitAnimation = false, ...props }, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/60",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
exitAnimation &&
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
{...props}
/>
);
},
);
type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
maxWidthClass?: string;
exitAnimation?: boolean;
srTitle?: string;
srDescription?: string;
};
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
function DialogContent(
{
className,
children,
showClose = true,
maxWidthClass = "max-w-md",
exitAnimation = false,
srTitle,
srDescription,
...props
},
ref,
) {
const { t } = useTranslation();
return (
<DialogPrimitive.Portal>
<Overlay exitAnimation={exitAnimation}>
<DialogPrimitive.Content
ref={ref}
className={cn(
"relative z-[52] mx-auto w-full outline-none ring-0",
"focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
"rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
exitAnimation &&
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1",
"duration-150 ease-out",
maxWidthClass,
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>
{srTitle ?? t("common.netbird")}
</DialogPrimitive.Title>
</VisuallyHidden>
{srDescription && (
<VisuallyHidden asChild>
<DialogPrimitive.Description>
{srDescription}
</DialogPrimitive.Description>
</VisuallyHidden>
)}
{children}
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
aria-label={t("common.close")}
>
<X className={"h-4 w-4"} aria-hidden={"true"} />
</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("mt-2 text-sm leading-snug text-nb-gray-400", className)}
{...props}
/>
);
});
type FooterProps = HTMLAttributes<HTMLDivElement> & {
separator?: boolean;
};
export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
<div className={cn(separator && "mt-6 border-t border-nb-gray-900")}>
<div
className={cn(
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end",
"[&>*]:w-full sm:[&>*]:w-auto",
"px-8 pt-6",
className,
)}
{...props}
/>
</div>
);

View File

@@ -0,0 +1,13 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogActionsProps = {
children: ReactNode;
className?: string;
};
export const DialogActions = ({ children, className }: DialogActionsProps) => (
<div className={cn("wails-no-draggable mx-auto flex w-full flex-col gap-3", className)}>
{children}
</div>
);

View File

@@ -0,0 +1,26 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogDescriptionProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
};
export const DialogDescription = ({
children,
className,
align = "center",
}: DialogDescriptionProps) => (
<p className={cn("w-full select-none text-sm text-nb-gray-300", alignClass[align], className)}>
{children}
</p>
);

View File

@@ -0,0 +1,35 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
type DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
id?: string;
};
export const DialogHeading = ({
children,
className,
align = "center",
id,
}: DialogHeadingProps) => (
<h2
id={id}
className={cn(
"w-full select-none text-base font-semibold text-nb-gray-50",
alignClass[align],
className,
)}
>
{children}
</h2>
);

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
}
export const DaemonOutdatedOverlay = () => {
const { t } = useTranslation();
const { isDaemonOutdated } = useStatus();
if (!isDaemonOutdated) return null;
return (
<div
className={
"wails-draggable fixed inset-0 z-[100] flex cursor-default select-none items-center justify-center bg-nb-gray-950 backdrop-blur-sm"
}
>
<div className={"flex max-w-lg flex-col items-center gap-5 px-8 text-center"}>
<div
className={
"flex h-11 w-11 items-center justify-center rounded-xl border border-nb-gray-900 bg-nb-gray-920 text-amber-500"
}
>
<AlertTriangleIcon size={20} />
</div>
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.outdated.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
import { AlertCircleIcon, BookText } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
}
export const DaemonUnavailableOverlay = () => {
const { t } = useTranslation();
const { isDaemonUnavailable } = useStatus();
if (!isDaemonUnavailable) return null;
return (
<div
className={
"wails-draggable fixed inset-0 z-[100] flex cursor-default select-none items-center justify-center bg-nb-gray-950 backdrop-blur-sm"
}
>
<div className={"flex max-w-lg flex-col items-center gap-5 px-8 text-center"}>
<div
className={
"flex h-11 w-11 items-center justify-center rounded-xl border border-nb-gray-900 bg-nb-gray-920 text-red-500"
}
>
<AlertCircleIcon size={20} />
</div>
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.unavailable.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>
{t("daemon.unavailable.description")}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
<BookText size={14} />
{t("daemon.unavailable.docsLink")}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { type ComponentType } from "react";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "@/components/SquareIcon";
import { isMacOS } from "@/lib/platform";
// Knob to shift the centered main-window content up/down together.
export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem");
export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`;
type Props = {
icon: ComponentType<LucideProps>;
title: string;
description?: string;
className?: string;
};
export const EmptyState = ({ icon, title, description, className }: Props) => {
return (
<div className={cn("py-12 text-center", className)}>
<div
className={"relative mx-auto flex max-w-sm flex-col items-center justify-start"}
style={{ top: contentTop("7.8rem") }}
>
<SquareIcon icon={icon} className={"mb-3"} />
<p className={"mb-1 text-[0.95rem] font-medium text-nb-gray-200"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-350"}>{description}</p>}
</div>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { type ComponentType } from "react";
import { FunnelXIcon, type LucideProps } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "./EmptyState";
type Props = {
icon?: ComponentType<LucideProps>;
title?: string;
description?: string;
};
export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => {
const { t } = useTranslation();
return (
<EmptyState
icon={icon}
title={title ?? t("common.noResults.title")}
description={description ?? t("common.noResults.description")}
className={"pointer-events-none relative -top-[3.8rem]"}
/>
);
};

View File

@@ -0,0 +1,16 @@
import { GlobeOffIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "./EmptyState";
export const NotConnectedState = () => {
const { t } = useTranslation();
return (
<div className={"relative top-[3rem] w-full"}>
<EmptyState
icon={GlobeOffIcon}
title={t("notConnected.title")}
description={t("notConnected.description")}
/>
</div>
);
};

View File

@@ -0,0 +1,374 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import {
forwardRef,
type InputHTMLAttributes,
type ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/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;
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
copy?: boolean;
}
const inputVariants = cva("", {
variants: {
variant: {
default: [
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
],
darker: [
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
],
error: [
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
],
warning: [
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
],
},
prefixSuffixVariant: {
default: [
"border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900",
],
error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"],
},
},
});
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
const stepAttr = el.step === "" ? 1 : Number(el.step);
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min === "" ? -Infinity : Number(el.min);
const max = el.max === "" ? Infinity : Number(el.max);
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;
return next;
}
function buildInputClassName(
opts: Readonly<{
variant: InputVariants["variant"];
hasCustomPrefix: boolean;
hasSuffix: boolean;
hasIcon: boolean;
readOnly?: boolean;
showStepper: boolean;
className?: string;
}>,
): string {
return cn(
inputVariants({ variant: opts.variant }),
"flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
"border",
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
opts.showStepper &&
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
opts.className,
);
}
function InputAffix({
content,
error,
disabled,
className,
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
return (
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"items-center whitespace-nowrap border",
disabled && "opacity-40",
className,
)}
>
{content}
</div>
);
}
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-40",
)}
>
{icon}
</div>
);
}
function InputSuffixSlot({
suffix,
disabled,
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-30",
)}
>
{suffix}
</div>
);
}
function NumberStepper({
error,
disabled,
onStep,
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex h-[40px] shrink-0 flex-col overflow-hidden",
"rounded-r-md border border-l-0",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "pointer-events-none opacity-40",
)}
>
<button
type={"button"}
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => onStep(1)}
className={
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800"
}
>
<ChevronUp size={12} aria-hidden={"true"} />
</button>
<button
type={"button"}
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => onStep(-1)}
className={cn(
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} aria-hidden={"true"} />
</button>
</div>
);
}
function FieldMessage({
id,
error,
warning,
}: Readonly<{ id?: string; error?: string; warning?: string }>) {
if (!error && !warning) return null;
return (
<span
id={id}
role={error ? "alert" : "status"}
className={cn(
"mt-2 inline-flex items-center gap-1 text-xs",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
type,
label,
customSuffix,
customPrefix,
icon,
maxWidthClass = "",
error,
warning,
variant = "default",
prefixClassName,
showPasswordToggle = false,
copy = false,
id,
...props
},
ref,
) {
const { t } = useTranslation();
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 fallbackId = `input-${reactId}`;
const inputId = id ?? (label ? fallbackId : undefined);
const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined;
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
const next = computeNextStepValue(el, delta);
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
const passwordToggle =
isPasswordType && showPasswordToggle ? (
<button
type={"button"}
onClick={() => setShowPassword((s) => !s)}
className={"pointer-events-auto transition-all hover:text-white"}
aria-label={t("common.togglePasswordVisibility")}
aria-pressed={showPassword}
>
{showPassword ? (
<EyeOff size={18} aria-hidden={"true"} />
) : (
<Eye size={18} aria-hidden={"true"} />
)}
</button>
) : null;
const onCopy = async () => {
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch (e) {
console.warn("copy to clipboard failed", e);
}
};
const copyToggle = copy ? (
<button
type={"button"}
onClick={onCopy}
className={"pointer-events-auto transition-all hover:text-white"}
aria-label={t("common.copy")}
>
{copied ? (
<Check size={16} aria-hidden={"true"} />
) : (
<Copy size={16} aria-hidden={"true"} />
)}
</button>
) : null;
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
const warningVariant = warning ? "warning" : variant;
const resolvedVariant = error ? "error" : warningVariant;
const inputClassName = buildInputClassName({
variant: resolvedVariant,
hasCustomPrefix: !!customPrefix,
hasSuffix: !!suffix,
hasIcon: !!icon,
readOnly: props.readOnly,
showStepper,
className,
});
return (
<div className={"flex w-full min-w-0 flex-col"}>
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<InputAffix
content={customPrefix}
error={error}
disabled={props.disabled}
className={prefixClassName}
/>
)}
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
<div className={"relative flex min-w-0 flex-grow"}>
<input
id={inputId}
type={inputType}
ref={setRefs}
aria-invalid={error ? true : undefined}
aria-describedby={
messageId
? [props["aria-describedby"], messageId].filter(Boolean).join(" ")
: props["aria-describedby"]
}
{...props}
className={inputClassName}
/>
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
</div>
{showStepper && (
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
)}
</div>
<FieldMessage id={messageId} error={error} warning={warning} />
</div>
);
});
export default Input;

View File

@@ -0,0 +1,59 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = InputHTMLAttributes<HTMLInputElement> & {
iconSize?: number;
shortcut?: ReactNode;
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
ref,
) {
const { t } = useTranslation();
return (
<div
role={"search"}
className={cn("flex h-10 items-center gap-2 px-1", disabled && "opacity-50")}
>
<SearchIcon
size={iconSize}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-300"}
/>
<input
ref={ref}
type={"search"}
disabled={disabled}
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
autoCorrect={"off"}
autoCapitalize={"off"}
spellCheck={false}
{...props}
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"border-none outline-none",
disabled && "cursor-not-allowed",
className,
)}
/>
{shortcut && (
<span
aria-hidden={"true"}
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] rounded px-1.5",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
>
{shortcut}
</span>
)}
</div>
);
});

View File

@@ -0,0 +1,102 @@
import React from "react";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { ToggleSwitch } from "@/components/switches/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;
loading?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
value,
onChange,
helpText,
label,
children,
disabled = false,
loading = false,
dataCy,
className,
labelClassName,
textWrapperClassName = "max-w-lg",
}: Readonly<Props>) {
const switchId = React.useId();
const descriptionId = React.useId();
if (loading) {
const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={cn("inline-block w-full text-left", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
<span className={shimmer}>{label}</span>
</Label>
<HelpText margin={false}>
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
{helpText}
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div
aria-hidden={"true"}
className={"h-[24px] w-[44px] animate-pulse rounded-full bg-[#25282d]"}
/>
</div>
</div>
</div>
);
}
return (
<div
{...(disabled ? { inert: "" } : {})}
className={cn(
"relative z-[1] cursor-default transition-all duration-300",
"inline-block w-full text-left",
disabled && "pointer-events-none opacity-30",
className,
)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label htmlFor={switchId} className={labelClassName}>
{label}
</Label>
<HelpText margin={false}>
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch
id={switchId}
checked={value}
onCheckedChange={onChange}
disabled={disabled}
dataCy={dataCy}
aria-describedby={helpText ? descriptionId : undefined}
/>
</div>
</div>
{children && value ? <div className={"mt-4"}>{children}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { motion } from "framer-motion";
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
type Props = {
value: string;
children: ReactNode;
className?: string;
};
export const SwitchItem = ({ value, children, className }: 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",
"cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
active
? "text-nb-gray-100"
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
className,
)}
>
{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,60 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, type ReactNode, useContext, useId, useMemo } 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;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const SwitchItemGroup = ({
value,
onChange,
children,
className,
disabled = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
value={value}
onValueChange={onChange}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
disabled && "pointer-events-none opacity-50",
className,
)}
>
{children}
</RadioGroup.Root>
</SwitchItemGroupContext.Provider>
);
};

View File

@@ -0,0 +1,77 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cva, type 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]",
large: "h-[36px] w-[66px]",
},
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 data-[state=unchecked]:translate-x-0",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
},
},
});
const ToggleSwitch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
SwitchVariants & { dataCy?: string }
>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => (
<SwitchPrimitives.Root
disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
"wails-no-draggable 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-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
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 dark:bg-white",
)}
/>
</SwitchPrimitives.Root>
));
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
export { ToggleSwitch };

View File

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

View File

@@ -0,0 +1,42 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react";
import { cn } from "@/lib/cn";
const labelVariants = cva(
"mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100",
);
type LabelProps = ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & {
as?: "label" | "div";
disabled?: boolean;
};
export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
{ className, as = "label", disabled = false, children, ...props },
ref,
) {
const classes = cn(
labelVariants(),
className,
"select-none transition-all duration-300",
disabled && "pointer-events-none opacity-30",
);
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;