mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 04:29:54 +00:00
add a11y keys
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -18,6 +19,7 @@ type CopyToClipboardProps = {
|
||||
iconClassName?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
variant?: CopyToClipboardVariant;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export const CopyToClipboard = ({
|
||||
@@ -29,7 +31,9 @@ export const CopyToClipboard = ({
|
||||
iconClassName,
|
||||
alwaysShowIcon = false,
|
||||
variant = "default",
|
||||
"aria-label": ariaLabel,
|
||||
}: CopyToClipboardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const wrapperRef = useRef<HTMLButtonElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -55,11 +59,16 @@ export const CopyToClipboard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedLabel =
|
||||
ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={wrapperRef}
|
||||
onClick={handleClick}
|
||||
aria-label={resolvedLabel}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
|
||||
className,
|
||||
@@ -74,12 +83,14 @@ export const CopyToClipboard = ({
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500 pointer-events-none"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 inline-flex relative top-[2px] right-[1px]",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
|
||||
@@ -77,6 +77,9 @@ export function LanguagePicker() {
|
||||
<button
|
||||
type={"button"}
|
||||
disabled={busy || languages.length === 0}
|
||||
aria-label={t("settings.general.language.label")}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
@@ -86,11 +89,19 @@ export function LanguagePicker() {
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
|
||||
<LanguagesIcon
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-200 shrink-0"}
|
||||
/>
|
||||
<span className={"truncate flex-1 text-left"}>
|
||||
{current ? labelFor(current) : "—"}
|
||||
</span>
|
||||
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-400 shrink-0"}
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -119,11 +130,19 @@ export function LanguagePicker() {
|
||||
)}
|
||||
>
|
||||
<div className={"px-1 pb-1"}>
|
||||
<div className={"group flex items-center gap-2 px-1 h-8"}>
|
||||
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
|
||||
<div
|
||||
role="search"
|
||||
className={"group flex items-center gap-2 px-1 h-8"}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-200 shrink-0"}
|
||||
/>
|
||||
<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",
|
||||
"outline-none border-none",
|
||||
@@ -162,6 +181,7 @@ export function LanguagePicker() {
|
||||
{labelFor(lang)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"w-4 shrink-0 flex items-center justify-center"
|
||||
}
|
||||
|
||||
@@ -18,10 +18,16 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P
|
||||
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={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
|
||||
<img
|
||||
src={netbirdLogo}
|
||||
alt={""}
|
||||
aria-hidden="true"
|
||||
className={"h-[0.8rem] aspect-[31/23] shrink-0"}
|
||||
/>
|
||||
{t("settings.general.management.cloud")}
|
||||
</SwitchItem>
|
||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||
|
||||
@@ -25,6 +25,7 @@ export const SquareIcon = ({
|
||||
className,
|
||||
}: SquareIconProps) => (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
|
||||
@@ -54,20 +54,25 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 ml-2 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<h2
|
||||
<span
|
||||
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>
|
||||
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
|
||||
</span>
|
||||
{adornment && (
|
||||
<div aria-hidden="true" className={"ml-auto mr-2 shrink-0"}>
|
||||
{adornment}
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -144,6 +144,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
@@ -170,13 +171,20 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<span className={"absolute inset-0 flex items-center justify-center"}>
|
||||
<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} /> : <Copy size={iconSize} />)}
|
||||
(copied ? (
|
||||
<Check size={iconSize} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={iconSize} aria-hidden="true" />
|
||||
))}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -4,14 +4,22 @@ 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 },
|
||||
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div className={"wails-draggable select-none flex flex-col items-center"}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={"wails-draggable select-none flex flex-col items-center"}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -32,6 +32,9 @@ export const ConfirmModal = ({
|
||||
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}
|
||||
@@ -43,6 +46,8 @@ export const ConfirmModal = ({
|
||||
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">
|
||||
@@ -54,7 +59,12 @@ export const ConfirmModal = ({
|
||||
</div>
|
||||
|
||||
<DialogActions className={"flex-row justify-end gap-2.5"}>
|
||||
<Button variant={"secondary"} size={"xs2"} disabled={busy} onClick={onCancel}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{resolvedCancel}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -20,7 +20,8 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
|
||||
"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",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
"duration-150 ease-out",
|
||||
className,
|
||||
)}
|
||||
@@ -34,6 +35,8 @@ 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>(
|
||||
@@ -44,6 +47,8 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-md",
|
||||
exitAnimation = false,
|
||||
srTitle,
|
||||
srDescription,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -70,8 +75,17 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
{...props}
|
||||
>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Title>
|
||||
{srTitle ?? t("common.netbird")}
|
||||
</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
{srDescription && (
|
||||
<VisuallyHidden asChild>
|
||||
<DialogPrimitive.Description>
|
||||
{srDescription}
|
||||
</DialogPrimitive.Description>
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
@@ -82,7 +96,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
)}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -13,10 +13,17 @@ type DialogHeadingProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: DialogAlign;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
|
||||
<p
|
||||
export const DialogHeading = ({
|
||||
children,
|
||||
className,
|
||||
align = "center",
|
||||
id,
|
||||
}: DialogHeadingProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
"w-full text-base font-semibold text-nb-gray-50 select-none",
|
||||
alignClass[align],
|
||||
@@ -24,5 +31,5 @@ export const DialogHeading = ({ children, className, align = "center" }: DialogH
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</h2>
|
||||
);
|
||||
|
||||
@@ -35,9 +35,7 @@ export const DaemonOutdatedOverlay = () => {
|
||||
<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>
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -8,16 +9,25 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
};
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
||||
{ iconSize = 16, className, disabled, shortcut, ...props },
|
||||
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
|
||||
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
|
||||
<div
|
||||
role="search"
|
||||
className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}
|
||||
>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-300 shrink-0"}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type={"text"}
|
||||
type={"search"}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
@@ -28,6 +38,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
|
||||
/>
|
||||
{shortcut && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 select-none",
|
||||
"inline-flex items-center justify-center",
|
||||
|
||||
@@ -31,13 +31,21 @@ export default function FancyToggleSwitch({
|
||||
labelClassName,
|
||||
textWrapperClassName = "max-w-lg",
|
||||
}: Readonly<Props>) {
|
||||
const switchId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
const childrenRef = React.useRef<HTMLDivElement>(null);
|
||||
const switchRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
if (loading) {
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div className={cn("inline-block text-left w-full", className)} aria-busy>
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className={cn("inline-block text-left w-full", className)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label className={labelClassName}>
|
||||
@@ -51,6 +59,7 @@ export default function FancyToggleSwitch({
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
|
||||
/>
|
||||
</div>
|
||||
@@ -62,26 +71,18 @@ export default function FancyToggleSwitch({
|
||||
const fromChildren = (target: EventTarget | null) =>
|
||||
target instanceof Node && childrenRef.current?.contains(target);
|
||||
|
||||
const handleToggle = (event: React.MouseEvent) => {
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
if (disabled || fromChildren(event.target)) return;
|
||||
onChange(!value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled || fromChildren(event.target)) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onChange(!value);
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
// Let the switch own its own click so focus + state stay together.
|
||||
if (target.closest("button,input,a,[role=switch]")) return;
|
||||
switchRef.current?.click();
|
||||
switchRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
role={"switch"}
|
||||
aria-checked={value}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"cursor-default transition-all duration-300 relative z-[1]",
|
||||
"inline-block text-left w-full",
|
||||
@@ -91,11 +92,24 @@ export default function FancyToggleSwitch({
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
<Label className={labelClassName}>{label}</Label>
|
||||
<HelpText margin={false}>{helpText}</HelpText>
|
||||
<Label as="div" className={labelClassName}>
|
||||
<label htmlFor={switchId} className={"cursor-default"}>
|
||||
{label}
|
||||
</label>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
<span id={descriptionId}>{helpText}</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<ToggleSwitch checked={value} onCheckedChange={onChange} dataCy={dataCy} />
|
||||
<ToggleSwitch
|
||||
ref={switchRef}
|
||||
id={switchId}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
dataCy={dataCy}
|
||||
aria-describedby={helpText ? descriptionId : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children && value ? (
|
||||
|
||||
@@ -23,6 +23,8 @@ type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
"aria-labelledby"?: string;
|
||||
};
|
||||
|
||||
export const SwitchItemGroup = ({
|
||||
@@ -31,6 +33,8 @@ export const SwitchItemGroup = ({
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-labelledby": ariaLabelledBy,
|
||||
}: Props) => {
|
||||
const layoutId = useId();
|
||||
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
||||
@@ -41,6 +45,8 @@ export const SwitchItemGroup = ({
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={cn(
|
||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
|
||||
@@ -35,7 +35,8 @@ const switchVariants = cva("", {
|
||||
],
|
||||
},
|
||||
"thumb-size": {
|
||||
default: "h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
|
||||
default:
|
||||
"h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
|
||||
small: "h-[14px] w-[14px] data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-[17px]",
|
||||
large: "h-[30px] w-[30px] data-[state=unchecked]:translate-x-[1px] data-[state=checked]:translate-x-[31px]",
|
||||
},
|
||||
|
||||
@@ -43,7 +43,9 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
setMode(saved);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ViewModeContext] load preferences failed", err));
|
||||
.catch((err: unknown) =>
|
||||
console.warn("[ViewModeContext] load preferences failed", err),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -35,8 +35,7 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified =
|
||||
toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
|
||||
@@ -34,11 +34,13 @@ export default function ErrorDialog() {
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading className={"text-balance"}>{title}</DialogHeading>
|
||||
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
|
||||
{title}
|
||||
</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
|
||||
@@ -52,11 +52,13 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
|
||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading>
|
||||
<DialogHeading id={"nb-browser-login-title"} className={"text-balance"}>
|
||||
{t("browserLogin.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
{t("browserLogin.notSeeing")}{" "}
|
||||
<button
|
||||
|
||||
@@ -235,17 +235,23 @@ export const MainConnectionStatusSwitch = () => {
|
||||
checked={isOn}
|
||||
onCheckedChange={handleSwitch}
|
||||
disabled={(isTransitioning && !canForceCancel) || unreachable}
|
||||
aria-label={t("connect.toggle.label")}
|
||||
aria-describedby={"nb-connection-status"}
|
||||
aria-busy={isTransitioning}
|
||||
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
|
||||
/>
|
||||
|
||||
<div className={"flex flex-col items-center"}>
|
||||
<h1
|
||||
<p
|
||||
id={"nb-connection-status"}
|
||||
role={"status"}
|
||||
aria-live={"polite"}
|
||||
className={
|
||||
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable mb-1"
|
||||
}
|
||||
>
|
||||
{t(STATUS_KEY[connState])}
|
||||
</h1>
|
||||
</p>
|
||||
<CopyToClipboard
|
||||
message={fqdn}
|
||||
variant={"bright"}
|
||||
@@ -270,6 +276,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
};
|
||||
|
||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasV6 = !!ipv6;
|
||||
|
||||
@@ -303,6 +310,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("connect.localIp.label")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"group relative inline-flex items-center outline-none cursor-default",
|
||||
"transition-colors",
|
||||
@@ -319,6 +329,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute -right-5 top-1/2 -translate-y-1/2",
|
||||
"shrink-0 text-nb-gray-300 transition-colors",
|
||||
@@ -352,6 +363,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
};
|
||||
|
||||
const IpRow = ({ value }: { value: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleClick = async () => {
|
||||
if (!value) return;
|
||||
@@ -367,6 +379,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={handleClick}
|
||||
aria-label={`${t("common.copy")} ${value}`}
|
||||
className={cn(
|
||||
"group/iprow relative flex items-center justify-between gap-3",
|
||||
"rounded-md px-2 py-1.5 text-left",
|
||||
@@ -375,7 +388,10 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
)}
|
||||
>
|
||||
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
|
||||
<span className={"shrink-0 inline-flex items-center text-nb-gray-200"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={"shrink-0 inline-flex items-center text-nb-gray-200"}
|
||||
>
|
||||
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -51,6 +51,8 @@ export const MainExitNodeSwitcher = () => {
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
active={!!active}
|
||||
aria-label={t("exitNodes.dropdown.trigger")}
|
||||
aria-haspopup="listbox"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -139,6 +141,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
active
|
||||
@@ -149,7 +152,9 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
<ExitNodeIcon size={14} />
|
||||
</div>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
|
||||
<span className={"block font-medium text-sm text-nb-gray-100 truncate"}>
|
||||
{title}
|
||||
</span>
|
||||
<TruncatedText
|
||||
text={description}
|
||||
className={
|
||||
@@ -157,7 +162,11 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
|
||||
<ChevronsUpDown
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-400 shrink-0"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -181,7 +190,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
{isActive && <Check size={16} aria-hidden="true" className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
@@ -204,7 +213,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
|
||||
{isActive && <Check size={16} aria-hidden="true" className={"shrink-0 text-netbird"} />}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ export const MainHeader = () => {
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
className={"select-none"}
|
||||
aria-label={t("header.menu.open")}
|
||||
aria-haspopup="menu"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -86,7 +88,11 @@ export const MainHeader = () => {
|
||||
<>
|
||||
<DropdownMenuItem onClick={openAbout}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpCircleIcon size={14} className={"text-netbird"} />
|
||||
<ArrowUpCircleIcon
|
||||
size={14}
|
||||
className={"text-netbird"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={"text-netbird"}>
|
||||
{t("header.menu.updateAvailable")}
|
||||
</span>
|
||||
@@ -97,7 +103,7 @@ export const MainHeader = () => {
|
||||
)}
|
||||
<DropdownMenuItem onClick={openSettings}>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Settings size={14} />
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
<span className="flex-1">{t("header.menu.settings")}</span>
|
||||
<DropdownMenuShortcut>
|
||||
{formatShortcut(SETTINGS_SHORTCUT)}
|
||||
@@ -125,6 +131,7 @@ export const MainHeader = () => {
|
||||
</DropdownMenu>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
||||
}
|
||||
@@ -172,11 +179,11 @@ type ViewModeItemProps = {
|
||||
};
|
||||
|
||||
const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
|
||||
<DropdownMenuItem onClick={onSelect}>
|
||||
<DropdownMenuItem onClick={onSelect} role="menuitemradio" aria-checked={selected}>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Icon size={14} />
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{selected && <Check size={14} className="text-netbird" />}
|
||||
{selected && <Check size={14} className="text-netbird" aria-hidden="true" />}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -82,6 +82,11 @@ const AdvancedAppRightPanel = () => {
|
||||
className={"m-5 ml-0"}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
if (isConnected) el.removeAttribute("inert");
|
||||
else el.setAttribute("inert", "");
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
!isConnected && "pointer-events-none select-none",
|
||||
@@ -89,7 +94,12 @@ const AdvancedAppRightPanel = () => {
|
||||
aria-hidden={!isConnected}
|
||||
>
|
||||
<Navigation />
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
<div
|
||||
role={"tabpanel"}
|
||||
id={`nb-tabpanel-${section}`}
|
||||
aria-labelledby={`nb-tab-${section}`}
|
||||
className={"flex-1 min-h-0 flex flex-col"}
|
||||
>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,12 @@ export const Navigation = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"wails-no-draggable shrink-0 flex items-stretch "}>
|
||||
<div
|
||||
role={"tablist"}
|
||||
aria-orientation={"horizontal"}
|
||||
aria-label={t("nav.peers.title")}
|
||||
className={"wails-no-draggable shrink-0 flex items-stretch "}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.value === section;
|
||||
const isDisabled = !isConnected && !isActive;
|
||||
@@ -52,6 +57,11 @@ export const Navigation = () => {
|
||||
<button
|
||||
key={tab.value}
|
||||
type={"button"}
|
||||
role={"tab"}
|
||||
aria-selected={isActive}
|
||||
aria-controls={`nb-tabpanel-${tab.value}`}
|
||||
id={`nb-tab-${tab.value}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => setSection(tab.value)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
@@ -62,9 +72,10 @@ export const Navigation = () => {
|
||||
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 h-px transition-all",
|
||||
isActive
|
||||
|
||||
@@ -37,6 +37,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-200",
|
||||
@@ -45,11 +46,11 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -58,13 +59,18 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
<DropdownMenuItem
|
||||
key={f.value}
|
||||
onClick={() => handleSelect(f.value)}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
className={"gap-2"}
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span className={"w-4 shrink-0 flex items-center justify-center"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={"w-4 shrink-0 flex items-center justify-center"}
|
||||
>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
@@ -174,10 +168,7 @@ export const Networks = () => {
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport
|
||||
ref={setScrollParent}
|
||||
className={"h-full w-full"}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && (
|
||||
<NetworksList
|
||||
data={filtered}
|
||||
@@ -217,6 +208,7 @@ export const Networks = () => {
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onBulkClick}
|
||||
aria-label={t("networks.bulk.label")}
|
||||
className={cn(
|
||||
"inline-flex items-center h-8 px-3 rounded-md",
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
@@ -252,6 +244,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
components={{ Header: NetworksHeader }}
|
||||
itemContent={(_, n) => (
|
||||
<div
|
||||
role="listitem"
|
||||
className={cn(
|
||||
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
@@ -260,7 +253,8 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={n.id}
|
||||
aria-label={t("networks.row.toggle", { name: n.id })}
|
||||
aria-pressed={n.selected}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={"absolute inset-0 cursor-pointer"}
|
||||
/>
|
||||
@@ -282,12 +276,11 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
||||
</div>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
<div className={"shrink-0 self-center relative"}>
|
||||
<NetworkToggle
|
||||
checked={n.selected}
|
||||
onChange={() => onToggle(n.id, n.selected)}
|
||||
label={n.selected ? t("networks.selected") : t("networks.unselected")}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={"shrink-0 self-center relative pointer-events-none"}
|
||||
>
|
||||
<NetworkToggle checked={n.selected} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -299,6 +292,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
const Icon = resourceIconFor(type);
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
@@ -397,23 +391,16 @@ const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
||||
|
||||
type ToggleProps = {
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
mixed?: boolean;
|
||||
};
|
||||
|
||||
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
|
||||
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
|
||||
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
role={"switch"}
|
||||
aria-checked={mixed ? "mixed" : checked}
|
||||
aria-label={label}
|
||||
onClick={onChange}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
|
||||
"transition-colors cursor-pointer wails-no-draggable",
|
||||
"transition-colors wails-no-draggable",
|
||||
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
|
||||
mixed && "opacity-60",
|
||||
)}
|
||||
@@ -424,6 +411,6 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
|
||||
mixed ? "translate-x-2.5" : checkedTranslate,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,6 +105,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
role={"dialog"}
|
||||
aria-modal={"true"}
|
||||
aria-labelledby={"nb-peer-detail-title"}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
@@ -128,10 +131,12 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<ArrowLeftIcon size={16} />
|
||||
<ArrowLeftIcon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t(peerStatusLabelKey(selected.connStatus))}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
dotClass(selected.connStatus),
|
||||
@@ -144,7 +149,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
className={"flex-1 min-w-0"}
|
||||
iconClassName={"top-[2px]"}
|
||||
>
|
||||
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
|
||||
<span
|
||||
id={"nb-peer-detail-title"}
|
||||
className={"text-sm font-medium text-nb-gray-100 truncate"}
|
||||
>
|
||||
{shortenDns(selected.fqdn) || selected.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
@@ -154,6 +162,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label={t("peers.details.refresh")}
|
||||
aria-busy={refreshing}
|
||||
className={cn(
|
||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
@@ -164,6 +173,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
>
|
||||
<RefreshCwIcon
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={refreshing ? "animate-spin" : undefined}
|
||||
/>
|
||||
</button>
|
||||
@@ -270,12 +280,16 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
||||
<ArrowDownIcon size={13} className={"text-sky-400"} />
|
||||
<ArrowDownIcon
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className={"text-sky-400"}
|
||||
/>
|
||||
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
|
||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
|
||||
</div>
|
||||
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
||||
<ArrowUpIcon size={13} className={"text-netbird"} />
|
||||
<ArrowUpIcon size={13} aria-hidden="true" className={"text-netbird"} />
|
||||
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
|
||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
|
||||
</div>
|
||||
@@ -372,6 +386,8 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"shrink-0 inline-flex items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
@@ -383,6 +399,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
{networks.length}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
@@ -432,7 +449,11 @@ const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) =
|
||||
|
||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
|
||||
<Icon size={14} className={cn("text-nb-gray-100 shrink-0", iconClassName)} />
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={cn("text-nb-gray-100 shrink-0", iconClassName)}
|
||||
/>
|
||||
<span className={"text-nb-gray-200 shrink-0 font-semibold"}>{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -37,6 +37,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
aria-label={t("common.filter")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||
"text-sm text-nb-gray-200",
|
||||
@@ -45,11 +46,11 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
>
|
||||
<ListFilter size={14} className={"shrink-0"} />
|
||||
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
@@ -58,13 +59,18 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
<DropdownMenuItem
|
||||
key={f.value}
|
||||
onClick={() => handleSelect(f.value)}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
className={"gap-2"}
|
||||
>
|
||||
<span className={"flex-1 truncate"}>
|
||||
{f.label}{" "}
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</span>
|
||||
<span className={"w-4 shrink-0 flex items-center justify-center"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={"w-4 shrink-0 flex items-center justify-center"}
|
||||
>
|
||||
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -136,13 +136,8 @@ export const Peers = () => {
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport
|
||||
ref={setScrollParent}
|
||||
className={"h-full w-full"}
|
||||
>
|
||||
{scrollParent && (
|
||||
<PeersList data={filtered} scrollParent={scrollParent} />
|
||||
)}
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
@@ -183,8 +178,11 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
components={{ Header: ListTopSpacer }}
|
||||
itemContent={(_, peer) => {
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
className={cn(
|
||||
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
@@ -193,12 +191,17 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={shortenDns(peer.fqdn)}
|
||||
aria-label={t("peers.row.label", {
|
||||
name: peerName,
|
||||
status: statusLabel,
|
||||
})}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={"absolute inset-0 cursor-default"}
|
||||
/>
|
||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={statusLabel}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
|
||||
dotClass(peer.connStatus),
|
||||
@@ -246,6 +249,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
@@ -33,6 +33,8 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
const { t } = useTranslation();
|
||||
const { mdm } = useRestrictions();
|
||||
const managedManagementUrl = mdm.managementURL;
|
||||
const nameId = useId();
|
||||
const urlId = useId();
|
||||
const [name, setName] = useState("");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
@@ -126,13 +128,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
maxWidthClass="max-w-md"
|
||||
showClose={false}
|
||||
className="py-7"
|
||||
srTitle={t("profile.dialog.title")}
|
||||
srDescription={t("profile.dialog.description")}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6 px-7">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
<Label htmlFor={nameId} className={"mb-0.5"}>
|
||||
{t("profile.dialog.nameLabel")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
@@ -140,6 +144,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
id={nameId}
|
||||
ref={nameRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
@@ -171,8 +176,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
/>
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<Input
|
||||
id={urlId}
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
aria-label={t("settings.general.management.label")}
|
||||
placeholder={t(
|
||||
"settings.general.management.urlPlaceholder",
|
||||
)}
|
||||
|
||||
@@ -124,7 +124,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<Settings2 size={14} aria-hidden="true" className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
@@ -138,9 +138,17 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
};
|
||||
|
||||
const ProfileTriggerSkeleton = () => (
|
||||
<div className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable">
|
||||
<div className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0" />
|
||||
<div className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" />
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0"
|
||||
/>
|
||||
<div aria-hidden="true" className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -150,11 +158,14 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
|
||||
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
||||
function ProfileTriggerButton({ name, className, ...props }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={t("header.profile.switch")}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
"h-10 flex items-center gap-2 px-3 rounded-lg outline-none cursor-default select-none wails-no-draggable",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
@@ -165,11 +176,19 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
|
||||
<Icon
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-200 shrink-0 wails-no-draggable"}
|
||||
/>
|
||||
<span className={"text-sm font-medium truncate max-w-[140px] wails-no-draggable"}>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown size={14} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-200 shrink-0 wails-no-draggable"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -199,7 +218,11 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
|
||||
<Check
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
|
||||
/>
|
||||
)}
|
||||
</Command.Item>
|
||||
);
|
||||
|
||||
@@ -128,7 +128,10 @@ export function ProfilesTab() {
|
||||
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<table className={"w-full text-sm"}>
|
||||
<table
|
||||
className={"w-full text-sm"}
|
||||
aria-label={t("settings.profiles.section.profiles")}
|
||||
>
|
||||
<tbody>
|
||||
{ordered.map((profile) => (
|
||||
<ProfileRow
|
||||
@@ -149,7 +152,11 @@ export function ProfilesTab() {
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
}
|
||||
>
|
||||
<UserCircle size={28} className={"text-nb-gray-500 mb-2"} />
|
||||
<UserCircle
|
||||
size={28}
|
||||
aria-hidden="true"
|
||||
className={"text-nb-gray-500 mb-2"}
|
||||
/>
|
||||
<p className={"text-sm font-semibold text-nb-gray-200"}>
|
||||
{t("settings.profiles.emptyTitle")}
|
||||
</p>
|
||||
@@ -162,7 +169,7 @@ export function ProfilesTab() {
|
||||
|
||||
<SettingsBottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle size={14} />
|
||||
<PlusCircle size={14} aria-hidden="true" />
|
||||
{t("settings.profiles.addProfile")}
|
||||
</Button>
|
||||
</SettingsBottomBar>
|
||||
@@ -201,6 +208,7 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-nb-gray-200 shrink-0",
|
||||
|
||||
@@ -346,7 +354,7 @@ const ActionIconButton = ({
|
||||
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
if (hidden) return button;
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function SessionExpirationDialog() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
|
||||
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading>
|
||||
<DialogHeading id={"nb-session-expiration-title"}>
|
||||
{expired ? t("sessionExpiration.expired") : activeTitle}
|
||||
</DialogHeading>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -7,12 +7,20 @@ import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/>
|
||||
<path
|
||||
d={
|
||||
"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
||||
<path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/>
|
||||
<path
|
||||
d={
|
||||
"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
@@ -82,10 +90,9 @@ export function SettingsAbout() {
|
||||
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
|
||||
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
<p
|
||||
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
|
||||
onClick={handleVersionClick}
|
||||
>
|
||||
@@ -99,7 +106,7 @@ export function SettingsAbout() {
|
||||
) : (
|
||||
t("settings.about.client", { version: daemonVersion })
|
||||
)}
|
||||
</button>
|
||||
</p>
|
||||
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
@@ -131,7 +138,7 @@ export function SettingsAbout() {
|
||||
"inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
|
||||
}
|
||||
>
|
||||
<Icon className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
<Icon aria-hidden="true" className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
@@ -21,6 +21,7 @@ export function SettingsGeneral() {
|
||||
const { mdm, features } = useRestrictions();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
const prevMode = useRef(mode);
|
||||
useEffect(() => {
|
||||
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
|
||||
@@ -63,7 +64,9 @@ export function SettingsGeneral() {
|
||||
<div>
|
||||
<div className={"flex items-start gap-3"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<Label as={"div"}>{t("settings.general.management.label")}</Label>
|
||||
<Label htmlFor={managementUrlId}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
<HelpText>{t("settings.general.management.help")}</HelpText>
|
||||
</div>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||
@@ -71,6 +74,7 @@ export function SettingsGeneral() {
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"flex items-start gap-3 mt-2"}>
|
||||
<Input
|
||||
id={managementUrlId}
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SettingsNavigation = () => {
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.List aria-label={t("settings.nav.label")}>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
import { type ChangeEvent, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,11 +98,12 @@ export function SettingsSSH() {
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
|
||||
<Label htmlFor={jwtTtlId}>{t("settings.ssh.jwtTtl.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
id={jwtTtlId}
|
||||
type={"number"}
|
||||
min={0}
|
||||
value={jwtTtlInput}
|
||||
|
||||
@@ -10,7 +10,11 @@ export const SectionGroup = ({
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
|
||||
<section
|
||||
aria-label={title}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}
|
||||
>
|
||||
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||
{title}
|
||||
</h2>
|
||||
@@ -20,7 +24,7 @@ export const SectionGroup = ({
|
||||
|
||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={"h-[3.2rem] shrink-0"} aria-hidden />
|
||||
<div className={"h-[3.2rem] shrink-0"} aria-hidden="true" />
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
@@ -22,6 +22,7 @@ const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const durationId = useId();
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
@@ -94,7 +95,7 @@ export function SettingsTroubleshooting() {
|
||||
/>
|
||||
<div className={"flex items-center gap-6 justify-between"}>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"} disabled={!capture}>
|
||||
<Label htmlFor={durationId} disabled={!capture}>
|
||||
{t("settings.troubleshooting.duration.label")}
|
||||
</Label>
|
||||
<HelpText margin={false} disabled={!capture}>
|
||||
@@ -103,6 +104,7 @@ export function SettingsTroubleshooting() {
|
||||
</div>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
id={durationId}
|
||||
type={"number"}
|
||||
min={1}
|
||||
max={30}
|
||||
@@ -244,6 +246,7 @@ function DoneResult({
|
||||
<Input
|
||||
value={result.path}
|
||||
readOnly
|
||||
aria-label={t("settings.troubleshooting.done.savedTitle")}
|
||||
customSuffix={
|
||||
<button
|
||||
type={"button"}
|
||||
@@ -251,7 +254,7 @@ function DoneResult({
|
||||
className={"pointer-events-auto hover:text-white transition-all"}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
<FolderOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -259,6 +262,7 @@ function DoneResult({
|
||||
|
||||
{uploadFailed && (
|
||||
<div
|
||||
role="alert"
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
}
|
||||
@@ -292,7 +296,7 @@ function DoneResult({
|
||||
className={"w-full"}
|
||||
onClick={onRevealPath}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
{t("settings.troubleshooting.done.openFolder")}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -159,5 +159,12 @@ export default function WelcomeDialog() {
|
||||
}
|
||||
}, [initial, step, handleTrayContinue, handleManagementContinue]);
|
||||
|
||||
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
ref={contentRef}
|
||||
aria-labelledby={step === "tray" ? "nb-welcome-title" : "nb-welcome-management-title"}
|
||||
>
|
||||
{content}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ export function WelcomeStepManagement({
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
||||
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
|
||||
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
|
||||
{t("welcome.management.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>
|
||||
{t("welcome.management.description")}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -35,7 +35,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col w-full gap-1"}>
|
||||
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t("welcome.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Update verfügbar"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Menü öffnen"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Profil wechseln"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "NetBird-Verbindung umschalten"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Lokale IP-Adressen"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Suchen"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filtern"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Exit-Node auswählen"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Details öffnen für {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Peer-Details"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "{name} umschalten"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Alle sichtbaren Ressourcen umschalten"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Einstellungsbereiche"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Zu Profil \"{name}\" wechseln?"
|
||||
},
|
||||
|
||||
@@ -479,6 +479,50 @@
|
||||
"message": "Update Available",
|
||||
"description": "'More' menu item / badge shown when an update is available."
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Open menu",
|
||||
"description": "Accessibility label for the header's more (⋮) button that opens the menu."
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Switch profile",
|
||||
"description": "Accessibility label for the header's profile selector button."
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Toggle NetBird connection",
|
||||
"description": "Accessibility label for the large connect/disconnect toggle on the main page."
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Local IP addresses",
|
||||
"description": "Accessibility label for the local IP selector that toggles between IPv4 and IPv6 on the main page."
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Search",
|
||||
"description": "Accessibility label for a generic search input."
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filter",
|
||||
"description": "Accessibility label for a generic filter control."
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Select exit node",
|
||||
"description": "Accessibility label for the exit-node picker button at the bottom of the main page."
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Open details for {name}, {status}",
|
||||
"description": "Accessibility label for a peer row in the list. {name} is the peer name and {status} is the connection status; keep both placeholders."
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Peer details",
|
||||
"description": "Accessibility title (announced to screen readers) for the peer details dialog/panel."
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Toggle {name}",
|
||||
"description": "Accessibility label for the row-wide toggle on a network/resource. {name} is the resource id; keep the placeholder."
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Toggle all visible resources",
|
||||
"description": "Accessibility label for the bulk enable/disable button at the bottom of the resources list."
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Switch Profile to \"{name}\"?",
|
||||
"description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes."
|
||||
@@ -599,6 +643,10 @@
|
||||
"message": "Debug Bundle Failed",
|
||||
"description": "Error-dialog title when creating the debug bundle fails."
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Settings sections",
|
||||
"description": "Accessibility label for the Settings page's side navigation (list of section tabs)."
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "General",
|
||||
"description": "Settings tab label: General. Keep short."
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Actualización disponible"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Abrir menú"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Cambiar de perfil"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Conmutar conexión NetBird"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Direcciones IP locales"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Buscar"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filtrar"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Seleccionar nodo de salida"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Abrir detalles de {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Detalles del par"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Conmutar {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Conmutar todos los recursos visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Secciones de configuración"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "¿Cambiar el perfil a «{name}»?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Mise à jour disponible"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Ouvrir le menu"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Changer de profil"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Activer/désactiver la connexion NetBird"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Adresses IP locales"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Rechercher"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filtrer"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Sélectionner un nœud de sortie"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Ouvrir les détails pour {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Détails du pair"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Activer/désactiver {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Activer/désactiver toutes les ressources visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sections des paramètres"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Basculer vers le profil « {name} » ?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Frissítés elérhető"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Menü megnyitása"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Profilváltás"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "NetBird-kapcsolat be/ki"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Helyi IP-címek"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Keresés"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Szűrés"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Kilépőcsomópont kiválasztása"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Részletek megnyitása: {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Társ részletei"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "{name} be/ki"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Összes látható erőforrás be/ki"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Beállítások szakaszai"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Aggiornamento disponibile"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Apri menu"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Cambia profilo"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Attiva/disattiva connessione NetBird"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Indirizzi IP locali"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Cerca"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filtra"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Seleziona nodo di uscita"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Apri dettagli di {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Dettagli peer"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Attiva/disattiva {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Attiva/disattiva tutte le risorse visibili"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sezioni delle impostazioni"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Passare al profilo «{name}»?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Atualização disponível"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Abrir menu"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Trocar perfil"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Alternar conexão NetBird"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Endereços IP locais"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Pesquisar"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Filtrar"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Selecionar nó de saída"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Abrir detalhes de {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Detalhes do par"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Alternar {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Alternar todos os recursos visíveis"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Seções das configurações"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Alternar perfil para \"{name}\"?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "Доступно обновление"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "Открыть меню"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "Сменить профиль"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "Переключить подключение NetBird"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "Локальные IP-адреса"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "Поиск"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "Фильтр"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "Выбрать выходной узел"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "Открыть подробности для {name}, {status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "Сведения об узле"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "Переключить {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "Переключить все видимые ресурсы"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Разделы настроек"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Переключиться на профиль «{name}»?"
|
||||
},
|
||||
|
||||
@@ -359,6 +359,42 @@
|
||||
"header.menu.updateAvailable": {
|
||||
"message": "有可用更新"
|
||||
},
|
||||
"header.menu.open": {
|
||||
"message": "打开菜单"
|
||||
},
|
||||
"header.profile.switch": {
|
||||
"message": "切换配置文件"
|
||||
},
|
||||
"connect.toggle.label": {
|
||||
"message": "切换 NetBird 连接"
|
||||
},
|
||||
"connect.localIp.label": {
|
||||
"message": "本地 IP 地址"
|
||||
},
|
||||
"common.search": {
|
||||
"message": "搜索"
|
||||
},
|
||||
"common.filter": {
|
||||
"message": "筛选"
|
||||
},
|
||||
"exitNodes.dropdown.trigger": {
|
||||
"message": "选择出口节点"
|
||||
},
|
||||
"peers.row.label": {
|
||||
"message": "打开 {name} 的详情,{status}"
|
||||
},
|
||||
"peers.dialog.title": {
|
||||
"message": "对等节点详情"
|
||||
},
|
||||
"networks.row.toggle": {
|
||||
"message": "切换 {name}"
|
||||
},
|
||||
"networks.bulk.label": {
|
||||
"message": "切换所有可见资源"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "设置部分"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "切换到配置文件“{name}”?"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user