mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-22 21:39:08 +02:00
add a11y keys
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ type CopyToClipboardProps = {
|
|||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
alwaysShowIcon?: boolean;
|
alwaysShowIcon?: boolean;
|
||||||
variant?: CopyToClipboardVariant;
|
variant?: CopyToClipboardVariant;
|
||||||
|
"aria-label"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CopyToClipboard = ({
|
export const CopyToClipboard = ({
|
||||||
@@ -29,7 +31,9 @@ export const CopyToClipboard = ({
|
|||||||
iconClassName,
|
iconClassName,
|
||||||
alwaysShowIcon = false,
|
alwaysShowIcon = false,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
"aria-label": ariaLabel,
|
||||||
}: CopyToClipboardProps) => {
|
}: CopyToClipboardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const wrapperRef = useRef<HTMLButtonElement>(null);
|
const wrapperRef = useRef<HTMLButtonElement>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
aria-label={resolvedLabel}
|
||||||
|
aria-live="polite"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
|
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
|
||||||
className,
|
className,
|
||||||
@@ -74,12 +83,14 @@ export const CopyToClipboard = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
"absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500 pointer-events-none"
|
"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>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 inline-flex relative top-[2px] right-[1px]",
|
"shrink-0 inline-flex relative top-[2px] right-[1px]",
|
||||||
iconAlignment === "left" ? "order-first" : "order-last",
|
iconAlignment === "left" ? "order-first" : "order-last",
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function LanguagePicker() {
|
|||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
disabled={busy || languages.length === 0}
|
disabled={busy || languages.length === 0}
|
||||||
|
aria-label={t("settings.general.language.label")}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
|
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
|
||||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||||
@@ -86,11 +89,19 @@ export function LanguagePicker() {
|
|||||||
"disabled:opacity-50",
|
"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"}>
|
<span className={"truncate flex-1 text-left"}>
|
||||||
{current ? labelFor(current) : "—"}
|
{current ? labelFor(current) : "—"}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
||||||
@@ -119,11 +130,19 @@ export function LanguagePicker() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={"px-1 pb-1"}>
|
<div className={"px-1 pb-1"}>
|
||||||
<div className={"group flex items-center gap-2 px-1 h-8"}>
|
<div
|
||||||
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
|
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
|
<Command.Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t("settings.general.language.search")}
|
placeholder={t("settings.general.language.search")}
|
||||||
|
aria-label={t("settings.general.language.search")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||||
"outline-none border-none",
|
"outline-none border-none",
|
||||||
@@ -162,6 +181,7 @@ export function LanguagePicker() {
|
|||||||
{labelFor(lang)}
|
{labelFor(lang)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
"w-4 shrink-0 flex items-center justify-center"
|
"w-4 shrink-0 flex items-center justify-center"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,16 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P
|
|||||||
key={i18n.language}
|
key={i18n.language}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v as ManagementMode)}
|
onChange={(v) => onChange(v as ManagementMode)}
|
||||||
|
aria-label={t("settings.general.management.label")}
|
||||||
className={fullWidth ? "w-full" : undefined}
|
className={fullWidth ? "w-full" : undefined}
|
||||||
>
|
>
|
||||||
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
|
<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")}
|
{t("settings.general.management.cloud")}
|
||||||
</SwitchItem>
|
</SwitchItem>
|
||||||
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const SquareIcon = ({
|
|||||||
className,
|
className,
|
||||||
}: SquareIconProps) => (
|
}: SquareIconProps) => (
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
|
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
|
||||||
variantClass[variant],
|
variantClass[variant],
|
||||||
|
|||||||
@@ -54,20 +54,25 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={iconSize}
|
size={iconSize}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 ml-2 transition-colors duration-150",
|
"shrink-0 ml-2 transition-colors duration-150",
|
||||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<h2
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
|
||||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</span>
|
||||||
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
|
{adornment && (
|
||||||
|
<div aria-hidden="true" className={"ml-auto mr-2 shrink-0"}>
|
||||||
|
{adornment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
variant,
|
variant,
|
||||||
@@ -170,13 +171,20 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading && (
|
{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"} />
|
<Loader2 size={iconSize} className={"animate-spin"} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={cn("contents", loading && "invisible")}>
|
<span className={cn("contents", loading && "invisible")}>
|
||||||
{copy !== undefined &&
|
{copy !== undefined &&
|
||||||
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
(copied ? (
|
||||||
|
<Check size={iconSize} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Copy size={iconSize} aria-hidden="true" />
|
||||||
|
))}
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import { isMacOS } from "@/lib/platform.ts";
|
|||||||
|
|
||||||
type ConfirmDialogProps = {
|
type ConfirmDialogProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
"aria-label"?: string;
|
||||||
|
"aria-labelledby"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
|
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
|
||||||
{ children },
|
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
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
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const ConfirmModal = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedCancel = cancelLabel ?? t("common.cancel");
|
const resolvedCancel = cancelLabel ?? t("common.cancel");
|
||||||
|
|
||||||
|
const srTitle = typeof title === "string" ? title : undefined;
|
||||||
|
const srDescription = typeof description === "string" ? description : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
open={open}
|
open={open}
|
||||||
@@ -43,6 +46,8 @@ export const ConfirmModal = ({
|
|||||||
maxWidthClass="max-w-sm"
|
maxWidthClass="max-w-sm"
|
||||||
showClose={false}
|
showClose={false}
|
||||||
className="py-5"
|
className="py-5"
|
||||||
|
srTitle={srTitle}
|
||||||
|
srDescription={srDescription}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5 px-5">
|
<div className="flex flex-col gap-5 px-5">
|
||||||
@@ -54,7 +59,12 @@ export const ConfirmModal = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogActions className={"flex-row justify-end gap-2.5"}>
|
<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}
|
{resolvedCancel}
|
||||||
</Button>
|
</Button>
|
||||||
<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",
|
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||||
"bg-black/60",
|
"bg-black/60",
|
||||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
"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",
|
"duration-150 ease-out",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -34,6 +35,8 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
maxWidthClass?: string;
|
maxWidthClass?: string;
|
||||||
exitAnimation?: boolean;
|
exitAnimation?: boolean;
|
||||||
|
srTitle?: string;
|
||||||
|
srDescription?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
|
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
|
||||||
@@ -44,6 +47,8 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
|||||||
showClose = true,
|
showClose = true,
|
||||||
maxWidthClass = "max-w-md",
|
maxWidthClass = "max-w-md",
|
||||||
exitAnimation = false,
|
exitAnimation = false,
|
||||||
|
srTitle,
|
||||||
|
srDescription,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -70,8 +75,17 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<VisuallyHidden asChild>
|
<VisuallyHidden asChild>
|
||||||
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
|
<DialogPrimitive.Title>
|
||||||
|
{srTitle ?? t("common.netbird")}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
|
{srDescription && (
|
||||||
|
<VisuallyHidden asChild>
|
||||||
|
<DialogPrimitive.Description>
|
||||||
|
{srDescription}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
</VisuallyHidden>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
{showClose && (
|
{showClose && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
@@ -82,7 +96,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
|||||||
)}
|
)}
|
||||||
aria-label={t("common.close")}
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ type DialogHeadingProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
align?: DialogAlign;
|
align?: DialogAlign;
|
||||||
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
|
export const DialogHeading = ({
|
||||||
<p
|
children,
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
id,
|
||||||
|
}: DialogHeadingProps) => (
|
||||||
|
<h2
|
||||||
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-base font-semibold text-nb-gray-50 select-none",
|
"w-full text-base font-semibold text-nb-gray-50 select-none",
|
||||||
alignClass[align],
|
alignClass[align],
|
||||||
@@ -24,5 +31,5 @@ export const DialogHeading = ({ children, className, align = "center" }: DialogH
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</h2>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ export const DaemonOutdatedOverlay = () => {
|
|||||||
<p className={"text-base font-medium text-nb-gray-50"}>
|
<p className={"text-base font-medium text-nb-gray-50"}>
|
||||||
{t("daemon.outdated.title")}
|
{t("daemon.outdated.title")}
|
||||||
</p>
|
</p>
|
||||||
<p className={"text-sm text-nb-gray-300"}>
|
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||||
{t("daemon.outdated.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={"wails-no-draggable"}>
|
<div className={"wails-no-draggable"}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { SearchIcon } from "lucide-react";
|
import { SearchIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@@ -8,16 +9,25 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
||||||
{ iconSize = 16, className, disabled, shortcut, ...props },
|
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
|
<div
|
||||||
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
|
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
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type={"text"}
|
type={"search"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
"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 && (
|
{shortcut && (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 select-none",
|
"shrink-0 select-none",
|
||||||
"inline-flex items-center justify-center",
|
"inline-flex items-center justify-center",
|
||||||
|
|||||||
@@ -31,13 +31,21 @@ export default function FancyToggleSwitch({
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
textWrapperClassName = "max-w-lg",
|
textWrapperClassName = "max-w-lg",
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
|
const switchId = React.useId();
|
||||||
|
const descriptionId = React.useId();
|
||||||
const childrenRef = React.useRef<HTMLDivElement>(null);
|
const childrenRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const switchRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
const shimmer =
|
const shimmer =
|
||||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||||
return (
|
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={"flex justify-between gap-10"}>
|
||||||
<div className={cn(textWrapperClassName)}>
|
<div className={cn(textWrapperClassName)}>
|
||||||
<Label className={labelClassName}>
|
<Label className={labelClassName}>
|
||||||
@@ -51,6 +59,7 @@ export default function FancyToggleSwitch({
|
|||||||
</div>
|
</div>
|
||||||
<div className={"mt-2 pr-1"}>
|
<div className={"mt-2 pr-1"}>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
|
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,26 +71,18 @@ export default function FancyToggleSwitch({
|
|||||||
const fromChildren = (target: EventTarget | null) =>
|
const fromChildren = (target: EventTarget | null) =>
|
||||||
target instanceof Node && childrenRef.current?.contains(target);
|
target instanceof Node && childrenRef.current?.contains(target);
|
||||||
|
|
||||||
const handleToggle = (event: React.MouseEvent) => {
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
if (disabled || fromChildren(event.target)) return;
|
if (disabled || fromChildren(event.target)) return;
|
||||||
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;
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
switchRef.current?.click();
|
||||||
if (disabled || fromChildren(event.target)) return;
|
switchRef.current?.focus();
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
onChange(!value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={handleToggle}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
tabIndex={-1}
|
|
||||||
role={"switch"}
|
|
||||||
aria-checked={value}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-default transition-all duration-300 relative z-[1]",
|
"cursor-default transition-all duration-300 relative z-[1]",
|
||||||
"inline-block text-left w-full",
|
"inline-block text-left w-full",
|
||||||
@@ -91,11 +92,24 @@ export default function FancyToggleSwitch({
|
|||||||
>
|
>
|
||||||
<div className={"flex justify-between gap-10"}>
|
<div className={"flex justify-between gap-10"}>
|
||||||
<div className={cn(textWrapperClassName)}>
|
<div className={cn(textWrapperClassName)}>
|
||||||
<Label className={labelClassName}>{label}</Label>
|
<Label as="div" className={labelClassName}>
|
||||||
<HelpText margin={false}>{helpText}</HelpText>
|
<label htmlFor={switchId} className={"cursor-default"}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</Label>
|
||||||
|
<HelpText margin={false}>
|
||||||
|
<span id={descriptionId}>{helpText}</span>
|
||||||
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div className={"mt-2 pr-1"}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{children && value ? (
|
{children && value ? (
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Props = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
"aria-label"?: string;
|
||||||
|
"aria-labelledby"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SwitchItemGroup = ({
|
export const SwitchItemGroup = ({
|
||||||
@@ -31,6 +33,8 @@ export const SwitchItemGroup = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
"aria-labelledby": ariaLabelledBy,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const layoutId = useId();
|
const layoutId = useId();
|
||||||
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
||||||
@@ -41,6 +45,8 @@ export const SwitchItemGroup = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
||||||
disabled && "opacity-50 pointer-events-none",
|
disabled && "opacity-50 pointer-events-none",
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const switchVariants = cva("", {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"thumb-size": {
|
"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]",
|
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]",
|
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);
|
setMode(saved);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => console.warn("[ViewModeContext] load preferences failed", err));
|
.catch((err: unknown) =>
|
||||||
|
console.warn("[ViewModeContext] load preferences failed", err),
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export const formatErrorMessage = (e: unknown): string => {
|
|||||||
const envelope = toWailsEnvelope(e);
|
const envelope = toWailsEnvelope(e);
|
||||||
|
|
||||||
// Prefer the structured { short, long } the daemon classifier produced.
|
// Prefer the structured { short, long } the daemon classifier produced.
|
||||||
const classified =
|
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||||
toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
|
||||||
if (classified) {
|
if (classified) {
|
||||||
const { short, long } = classified;
|
const { short, long } = classified;
|
||||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ export default function ErrorDialog() {
|
|||||||
}, [close]);
|
}, [close]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog ref={contentRef}>
|
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
|
||||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||||
|
|
||||||
<div className={"flex flex-col items-center gap-1"}>
|
<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 && (
|
{message && (
|
||||||
<DialogDescription className={"text-balance"}>
|
<DialogDescription className={"text-balance"}>
|
||||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ export default function LoginWaitingForBrowserDialog() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog ref={contentRef}>
|
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
|
||||||
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
|
||||||
|
|
||||||
<div className={"flex flex-col items-center gap-2"}>
|
<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>
|
<DialogDescription>
|
||||||
{t("browserLogin.notSeeing")}{" "}
|
{t("browserLogin.notSeeing")}{" "}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -235,17 +235,23 @@ export const MainConnectionStatusSwitch = () => {
|
|||||||
checked={isOn}
|
checked={isOn}
|
||||||
onCheckedChange={handleSwitch}
|
onCheckedChange={handleSwitch}
|
||||||
disabled={(isTransitioning && !canForceCancel) || unreachable}
|
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")}
|
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={"flex flex-col items-center"}>
|
<div className={"flex flex-col items-center"}>
|
||||||
<h1
|
<p
|
||||||
|
id={"nb-connection-status"}
|
||||||
|
role={"status"}
|
||||||
|
aria-live={"polite"}
|
||||||
className={
|
className={
|
||||||
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable mb-1"
|
"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])}
|
{t(STATUS_KEY[connState])}
|
||||||
</h1>
|
</p>
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
message={fqdn}
|
message={fqdn}
|
||||||
variant={"bright"}
|
variant={"bright"}
|
||||||
@@ -270,6 +276,7 @@ export const MainConnectionStatusSwitch = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasV6 = !!ipv6;
|
const hasV6 = !!ipv6;
|
||||||
|
|
||||||
@@ -303,6 +310,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
|||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
|
aria-label={t("connect.localIp.label")}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative inline-flex items-center outline-none cursor-default",
|
"group relative inline-flex items-center outline-none cursor-default",
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
@@ -319,6 +329,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
|||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
size={14}
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute -right-5 top-1/2 -translate-y-1/2",
|
"absolute -right-5 top-1/2 -translate-y-1/2",
|
||||||
"shrink-0 text-nb-gray-300 transition-colors",
|
"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 IpRow = ({ value }: { value: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
@@ -367,6 +379,7 @@ const IpRow = ({ value }: { value: string }) => {
|
|||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
aria-label={`${t("common.copy")} ${value}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/iprow relative flex items-center justify-between gap-3",
|
"group/iprow relative flex items-center justify-between gap-3",
|
||||||
"rounded-md px-2 py-1.5 text-left",
|
"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={"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} />}
|
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const MainExitNodeSwitcher = () => {
|
|||||||
description={description}
|
description={description}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
active={!!active}
|
active={!!active}
|
||||||
|
aria-label={t("exitNodes.dropdown.trigger")}
|
||||||
|
aria-haspopup="listbox"
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
@@ -139,6 +141,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||||
active
|
active
|
||||||
@@ -149,7 +152,9 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
|||||||
<ExitNodeIcon size={14} />
|
<ExitNodeIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div className={"min-w-0 flex-1"}>
|
<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
|
<TruncatedText
|
||||||
text={description}
|
text={description}
|
||||||
className={
|
className={
|
||||||
@@ -157,7 +162,11 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -181,7 +190,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
|
<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>
|
</Command.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -204,7 +213,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
<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>
|
</Command.Item>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export const MainHeader = () => {
|
|||||||
icon={MoreVertical}
|
icon={MoreVertical}
|
||||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||||
className={"select-none"}
|
className={"select-none"}
|
||||||
|
aria-label={t("header.menu.open")}
|
||||||
|
aria-haspopup="menu"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -86,7 +88,11 @@ export const MainHeader = () => {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenuItem onClick={openAbout}>
|
<DropdownMenuItem onClick={openAbout}>
|
||||||
<div className="flex items-center gap-2">
|
<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"}>
|
<span className={"text-netbird"}>
|
||||||
{t("header.menu.updateAvailable")}
|
{t("header.menu.updateAvailable")}
|
||||||
</span>
|
</span>
|
||||||
@@ -97,7 +103,7 @@ export const MainHeader = () => {
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={openSettings}>
|
<DropdownMenuItem onClick={openSettings}>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<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>
|
<span className="flex-1">{t("header.menu.settings")}</span>
|
||||||
<DropdownMenuShortcut>
|
<DropdownMenuShortcut>
|
||||||
{formatShortcut(SETTINGS_SHORTCUT)}
|
{formatShortcut(SETTINGS_SHORTCUT)}
|
||||||
@@ -125,6 +131,7 @@ export const MainHeader = () => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{updateAvailable && (
|
{updateAvailable && (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
"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) => (
|
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">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<Icon size={14} />
|
<Icon size={14} aria-hidden="true" />
|
||||||
<span className="flex-1">{label}</span>
|
<span className="flex-1">{label}</span>
|
||||||
{selected && <Check size={14} className="text-netbird" />}
|
{selected && <Check size={14} className="text-netbird" aria-hidden="true" />}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ const AdvancedAppRightPanel = () => {
|
|||||||
className={"m-5 ml-0"}
|
className={"m-5 ml-0"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (!el) return;
|
||||||
|
if (isConnected) el.removeAttribute("inert");
|
||||||
|
else el.setAttribute("inert", "");
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||||
!isConnected && "pointer-events-none select-none",
|
!isConnected && "pointer-events-none select-none",
|
||||||
@@ -89,7 +94,12 @@ const AdvancedAppRightPanel = () => {
|
|||||||
aria-hidden={!isConnected}
|
aria-hidden={!isConnected}
|
||||||
>
|
>
|
||||||
<Navigation />
|
<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 === "peers" && <Peers />}
|
||||||
{section === "networks" && <Networks />}
|
{section === "networks" && <Networks />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export const Navigation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.value === section;
|
const isActive = tab.value === section;
|
||||||
const isDisabled = !isConnected && !isActive;
|
const isDisabled = !isConnected && !isActive;
|
||||||
@@ -52,6 +57,11 @@ export const Navigation = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
type={"button"}
|
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)}
|
onClick={() => setSection(tab.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -62,9 +72,10 @@ export const Navigation = () => {
|
|||||||
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default",
|
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 className={"text-sm font-normal"}>{tab.label}</span>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-x-0 bottom-0 h-px transition-all",
|
"absolute inset-x-0 bottom-0 h-px transition-all",
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
|||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={t("common.filter")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||||
"text-sm text-nb-gray-200",
|
"text-sm text-nb-gray-200",
|
||||||
@@ -45,11 +46,11 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
|||||||
"wails-no-draggable cursor-default",
|
"wails-no-draggable cursor-default",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ListFilter size={14} className={"shrink-0"} />
|
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
|
||||||
<span>
|
<span>
|
||||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||||
{filters.map((f) => {
|
{filters.map((f) => {
|
||||||
@@ -58,13 +59,18 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={f.value}
|
key={f.value}
|
||||||
onClick={() => handleSelect(f.value)}
|
onClick={() => handleSelect(f.value)}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={checked}
|
||||||
className={"gap-2"}
|
className={"gap-2"}
|
||||||
>
|
>
|
||||||
<span className={"flex-1 truncate"}>
|
<span className={"flex-1 truncate"}>
|
||||||
{f.label}{" "}
|
{f.label}{" "}
|
||||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||||
</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"} />}
|
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ComponentType,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
@@ -174,10 +168,7 @@ export const Networks = () => {
|
|||||||
<NoResults />
|
<NoResults />
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||||
<ScrollArea.Viewport
|
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||||
ref={setScrollParent}
|
|
||||||
className={"h-full w-full"}
|
|
||||||
>
|
|
||||||
{scrollParent && (
|
{scrollParent && (
|
||||||
<NetworksList
|
<NetworksList
|
||||||
data={filtered}
|
data={filtered}
|
||||||
@@ -217,6 +208,7 @@ export const Networks = () => {
|
|||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
onClick={onBulkClick}
|
onClick={onBulkClick}
|
||||||
|
aria-label={t("networks.bulk.label")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center h-8 px-3 rounded-md",
|
"inline-flex items-center h-8 px-3 rounded-md",
|
||||||
"text-xs font-medium text-nb-gray-100",
|
"text-xs font-medium text-nb-gray-100",
|
||||||
@@ -252,6 +244,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
|||||||
components={{ Header: NetworksHeader }}
|
components={{ Header: NetworksHeader }}
|
||||||
itemContent={(_, n) => (
|
itemContent={(_, n) => (
|
||||||
<div
|
<div
|
||||||
|
role="listitem"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
|
"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",
|
"hover:bg-nb-gray-900/40 transition-colors",
|
||||||
@@ -260,7 +253,8 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type={"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)}
|
onClick={() => onToggle(n.id, n.selected)}
|
||||||
className={"absolute inset-0 cursor-pointer"}
|
className={"absolute inset-0 cursor-pointer"}
|
||||||
/>
|
/>
|
||||||
@@ -282,12 +276,11 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<Subtitle network={n} />
|
<Subtitle network={n} />
|
||||||
</div>
|
</div>
|
||||||
<div className={"shrink-0 self-center relative"}>
|
<div
|
||||||
<NetworkToggle
|
aria-hidden="true"
|
||||||
checked={n.selected}
|
className={"shrink-0 self-center relative pointer-events-none"}
|
||||||
onChange={() => onToggle(n.id, n.selected)}
|
>
|
||||||
label={n.selected ? t("networks.selected") : t("networks.unselected")}
|
<NetworkToggle checked={n.selected} />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -299,6 +292,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
|||||||
const Icon = resourceIconFor(type);
|
const Icon = resourceIconFor(type);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
|
"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",
|
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||||
@@ -397,23 +391,16 @@ const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
|
|||||||
|
|
||||||
type ToggleProps = {
|
type ToggleProps = {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: () => void;
|
|
||||||
label: string;
|
|
||||||
mixed?: boolean;
|
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";
|
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
|
||||||
return (
|
return (
|
||||||
<button
|
<span
|
||||||
type={"button"}
|
|
||||||
role={"switch"}
|
|
||||||
aria-checked={mixed ? "mixed" : checked}
|
|
||||||
aria-label={label}
|
|
||||||
onClick={onChange}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
|
"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",
|
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
|
||||||
mixed && "opacity-60",
|
mixed && "opacity-60",
|
||||||
)}
|
)}
|
||||||
@@ -424,6 +411,6 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
|
|||||||
mixed ? "translate-x-2.5" : checkedTranslate,
|
mixed ? "translate-x-2.5" : checkedTranslate,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selected && (
|
{selected && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
role={"dialog"}
|
||||||
|
aria-modal={"true"}
|
||||||
|
aria-labelledby={"nb-peer-detail-title"}
|
||||||
initial={{ x: "100%" }}
|
initial={{ x: "100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "100%" }}
|
exit={{ x: "100%" }}
|
||||||
@@ -128,10 +131,12 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
|||||||
"wails-no-draggable",
|
"wails-no-draggable",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon size={16} />
|
<ArrowLeftIcon size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
|
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
|
||||||
<span
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={t(peerStatusLabelKey(selected.connStatus))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2 w-2 rounded-full shrink-0",
|
"h-2 w-2 rounded-full shrink-0",
|
||||||
dotClass(selected.connStatus),
|
dotClass(selected.connStatus),
|
||||||
@@ -144,7 +149,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
|||||||
className={"flex-1 min-w-0"}
|
className={"flex-1 min-w-0"}
|
||||||
iconClassName={"top-[2px]"}
|
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}
|
{shortenDns(selected.fqdn) || selected.ip}
|
||||||
</span>
|
</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
@@ -154,6 +162,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
|||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
aria-label={t("peers.details.refresh")}
|
aria-label={t("peers.details.refresh")}
|
||||||
|
aria-busy={refreshing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
"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",
|
"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
|
<RefreshCwIcon
|
||||||
size={14}
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
className={refreshing ? "animate-spin" : undefined}
|
className={refreshing ? "animate-spin" : undefined}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -270,12 +280,16 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
<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={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
|
||||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
|
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
<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={"sr-only"}>{t("peers.details.bytesSent")}:</span>
|
||||||
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
|
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,6 +386,8 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
|||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 inline-flex items-center gap-1 rounded",
|
"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",
|
"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}
|
{networks.length}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
size={12}
|
size={12}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn("transition-transform duration-150", open && "rotate-180")}
|
className={cn("transition-transform duration-150", open && "rotate-180")}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -432,7 +449,11 @@ const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) =
|
|||||||
|
|
||||||
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
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"}>
|
<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={"text-nb-gray-200 shrink-0 font-semibold"}>{label}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
|||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={t("common.filter")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
|
||||||
"text-sm text-nb-gray-200",
|
"text-sm text-nb-gray-200",
|
||||||
@@ -45,11 +46,11 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
|||||||
"wails-no-draggable cursor-default",
|
"wails-no-draggable cursor-default",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ListFilter size={14} className={"shrink-0"} />
|
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
|
||||||
<span>
|
<span>
|
||||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
|
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||||
{filters.map((f) => {
|
{filters.map((f) => {
|
||||||
@@ -58,13 +59,18 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={f.value}
|
key={f.value}
|
||||||
onClick={() => handleSelect(f.value)}
|
onClick={() => handleSelect(f.value)}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={checked}
|
||||||
className={"gap-2"}
|
className={"gap-2"}
|
||||||
>
|
>
|
||||||
<span className={"flex-1 truncate"}>
|
<span className={"flex-1 truncate"}>
|
||||||
{f.label}{" "}
|
{f.label}{" "}
|
||||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||||
</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"} />}
|
{checked && <CheckIcon size={14} className={"text-netbird"} />}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -136,13 +136,8 @@ export const Peers = () => {
|
|||||||
<NoResults />
|
<NoResults />
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||||
<ScrollArea.Viewport
|
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||||
ref={setScrollParent}
|
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||||
className={"h-full w-full"}
|
|
||||||
>
|
|
||||||
{scrollParent && (
|
|
||||||
<PeersList data={filtered} scrollParent={scrollParent} />
|
|
||||||
)}
|
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
<ScrollArea.Scrollbar
|
<ScrollArea.Scrollbar
|
||||||
orientation={"vertical"}
|
orientation={"vertical"}
|
||||||
@@ -183,8 +178,11 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
|||||||
components={{ Header: ListTopSpacer }}
|
components={{ Header: ListTopSpacer }}
|
||||||
itemContent={(_, peer) => {
|
itemContent={(_, peer) => {
|
||||||
const isConnected = peer.connStatus === "Connected";
|
const isConnected = peer.connStatus === "Connected";
|
||||||
|
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||||
|
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="listitem"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
|
"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",
|
"hover:bg-nb-gray-900/40 transition-colors",
|
||||||
@@ -193,12 +191,17 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
aria-label={shortenDns(peer.fqdn)}
|
aria-label={t("peers.row.label", {
|
||||||
|
name: peerName,
|
||||||
|
status: statusLabel,
|
||||||
|
})}
|
||||||
onClick={() => setSelected(peer)}
|
onClick={() => setSelected(peer)}
|
||||||
className={"absolute inset-0 cursor-default"}
|
className={"absolute inset-0 cursor-default"}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
|
<Tooltip content={statusLabel} side={"left"}>
|
||||||
<span
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={statusLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
|
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
|
||||||
dotClass(peer.connStatus),
|
dotClass(peer.connStatus),
|
||||||
@@ -246,6 +249,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
|||||||
)}
|
)}
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
size={16}
|
size={16}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
|
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
|
||||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
"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 { useTranslation } from "react-i18next";
|
||||||
import * as Dialog from "@/components/dialog/Dialog";
|
import * as Dialog from "@/components/dialog/Dialog";
|
||||||
import { Input } from "@/components/inputs/Input";
|
import { Input } from "@/components/inputs/Input";
|
||||||
@@ -33,6 +33,8 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mdm } = useRestrictions();
|
const { mdm } = useRestrictions();
|
||||||
const managedManagementUrl = mdm.managementURL;
|
const managedManagementUrl = mdm.managementURL;
|
||||||
|
const nameId = useId();
|
||||||
|
const urlId = useId();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -126,13 +128,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
|||||||
maxWidthClass="max-w-md"
|
maxWidthClass="max-w-md"
|
||||||
showClose={false}
|
showClose={false}
|
||||||
className="py-7"
|
className="py-7"
|
||||||
|
srTitle={t("profile.dialog.title")}
|
||||||
|
srDescription={t("profile.dialog.description")}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col gap-6 px-7">
|
<div className="flex flex-col gap-6 px-7">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className={"pl-1"}>
|
<div className={"pl-1"}>
|
||||||
<Label as={"div"} className={"mb-0.5"}>
|
<Label htmlFor={nameId} className={"mb-0.5"}>
|
||||||
{t("profile.dialog.nameLabel")}
|
{t("profile.dialog.nameLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<HelpText margin={false}>
|
<HelpText margin={false}>
|
||||||
@@ -140,6 +144,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
|||||||
</HelpText>
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
id={nameId}
|
||||||
ref={nameRef}
|
ref={nameRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t("profile.dialog.placeholder")}
|
placeholder={t("profile.dialog.placeholder")}
|
||||||
@@ -171,8 +176,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
|||||||
/>
|
/>
|
||||||
{mode === ManagementMode.SelfHosted && (
|
{mode === ManagementMode.SelfHosted && (
|
||||||
<Input
|
<Input
|
||||||
|
id={urlId}
|
||||||
ref={urlRef}
|
ref={urlRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
aria-label={t("settings.general.management.label")}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"settings.general.management.urlPlaceholder",
|
"settings.general.management.urlPlaceholder",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
"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">
|
<span className="truncate flex-1">
|
||||||
{t("profile.dropdown.manageProfiles")}
|
{t("profile.dropdown.manageProfiles")}
|
||||||
</span>
|
</span>
|
||||||
@@ -138,9 +138,17 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProfileTriggerSkeleton = () => (
|
const ProfileTriggerSkeleton = () => (
|
||||||
<div className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable">
|
<div
|
||||||
<div className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0" />
|
role="status"
|
||||||
<div className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,11 +158,14 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
|||||||
|
|
||||||
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
||||||
function ProfileTriggerButton({ name, className, ...props }, ref) {
|
function ProfileTriggerButton({ name, className, ...props }, ref) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={t("header.profile.switch")}
|
||||||
|
aria-haspopup="listbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 flex items-center gap-2 px-3 rounded-lg outline-none cursor-default select-none wails-no-draggable",
|
"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",
|
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||||
@@ -165,11 +176,19 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...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"}>
|
<span className={"text-sm font-medium truncate max-w-[140px] wails-no-draggable"}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -199,7 +218,11 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
|||||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{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>
|
</Command.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export function ProfilesTab() {
|
|||||||
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
|
"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>
|
<tbody>
|
||||||
{ordered.map((profile) => (
|
{ordered.map((profile) => (
|
||||||
<ProfileRow
|
<ProfileRow
|
||||||
@@ -149,7 +152,11 @@ export function ProfilesTab() {
|
|||||||
"flex flex-col items-center justify-center py-10 text-center"
|
"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"}>
|
<p className={"text-sm font-semibold text-nb-gray-200"}>
|
||||||
{t("settings.profiles.emptyTitle")}
|
{t("settings.profiles.emptyTitle")}
|
||||||
</p>
|
</p>
|
||||||
@@ -162,7 +169,7 @@ export function ProfilesTab() {
|
|||||||
|
|
||||||
<SettingsBottomBar>
|
<SettingsBottomBar>
|
||||||
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
|
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
|
||||||
<PlusCircle size={14} />
|
<PlusCircle size={14} aria-hidden="true" />
|
||||||
{t("settings.profiles.addProfile")}
|
{t("settings.profiles.addProfile")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsBottomBar>
|
</SettingsBottomBar>
|
||||||
@@ -201,6 +208,7 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={15}
|
size={15}
|
||||||
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-nb-gray-200 shrink-0",
|
"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",
|
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<Icon size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
if (hidden) return button;
|
if (hidden) return button;
|
||||||
|
|||||||
@@ -153,11 +153,11 @@ export default function SessionExpirationDialog() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog ref={contentRef}>
|
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
|
||||||
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
|
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
|
||||||
|
|
||||||
<div className={"flex flex-col items-center gap-1"}>
|
<div className={"flex flex-col items-center gap-1"}>
|
||||||
<DialogHeading>
|
<DialogHeading id={"nb-session-expiration-title"}>
|
||||||
{expired ? t("sessionExpiration.expired") : activeTitle}
|
{expired ? t("sessionExpiration.expired") : activeTitle}
|
||||||
</DialogHeading>
|
</DialogHeading>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import netbirdFull from "@/assets/logos/netbird-full.svg";
|
|||||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
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)]"
|
"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"}>
|
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||||
<button
|
<p
|
||||||
type={"button"}
|
|
||||||
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
|
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
|
||||||
onClick={handleVersionClick}
|
onClick={handleVersionClick}
|
||||||
>
|
>
|
||||||
@@ -99,7 +106,7 @@ export function SettingsAbout() {
|
|||||||
) : (
|
) : (
|
||||||
t("settings.about.client", { version: daemonVersion })
|
t("settings.about.client", { version: daemonVersion })
|
||||||
)}
|
)}
|
||||||
</button>
|
</p>
|
||||||
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
|
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
|
||||||
{guiVersion === "development" ? (
|
{guiVersion === "development" ? (
|
||||||
<span>
|
<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"
|
"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>
|
<span>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useId, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Button } from "@/components/buttons/Button";
|
||||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||||
@@ -21,6 +21,7 @@ export function SettingsGeneral() {
|
|||||||
const { mdm, features } = useRestrictions();
|
const { mdm, features } = useRestrictions();
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const managementUrlId = useId();
|
||||||
const prevMode = useRef(mode);
|
const prevMode = useRef(mode);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
|
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
|
||||||
@@ -63,7 +64,9 @@ export function SettingsGeneral() {
|
|||||||
<div>
|
<div>
|
||||||
<div className={"flex items-start gap-3"}>
|
<div className={"flex items-start gap-3"}>
|
||||||
<div className={"flex-1 min-w-0"}>
|
<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>
|
<HelpText>{t("settings.general.management.help")}</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<ManagementServerSwitch value={mode} onChange={setMode} />
|
<ManagementServerSwitch value={mode} onChange={setMode} />
|
||||||
@@ -71,6 +74,7 @@ export function SettingsGeneral() {
|
|||||||
{mode === ManagementMode.SelfHosted && (
|
{mode === ManagementMode.SelfHosted && (
|
||||||
<div className={"flex items-start gap-3 mt-2"}>
|
<div className={"flex items-start gap-3 mt-2"}>
|
||||||
<Input
|
<Input
|
||||||
|
id={managementUrlId}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={displayUrl}
|
value={displayUrl}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const SettingsNavigation = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
|
<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
|
<VerticalTabs.Trigger
|
||||||
value={"general"}
|
value={"general"}
|
||||||
icon={SlidersHorizontalIcon}
|
icon={SlidersHorizontalIcon}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { Label } from "@/components/typography/Label";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||||
import { useSettings } from "@/contexts/SettingsContext.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() {
|
export function SettingsSSH() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config, setField } = useSettings();
|
const { config, setField } = useSettings();
|
||||||
const isSSHServerEnabled = config.serverSshAllowed;
|
const isSSHServerEnabled = config.serverSshAllowed;
|
||||||
|
const jwtTtlId = useId();
|
||||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,11 +98,12 @@ export function SettingsSSH() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={"flex-1 max-w-md"}>
|
<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>
|
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div className={"w-40 shrink-0"}>
|
<div className={"w-40 shrink-0"}>
|
||||||
<Input
|
<Input
|
||||||
|
id={jwtTtlId}
|
||||||
type={"number"}
|
type={"number"}
|
||||||
min={0}
|
min={0}
|
||||||
value={jwtTtlInput}
|
value={jwtTtlInput}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export const SectionGroup = ({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
disabled?: boolean;
|
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"}>
|
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -20,7 +24,7 @@ export const SectionGroup = ({
|
|||||||
|
|
||||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
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={"absolute bottom-0 left-0 w-full"}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useId, type ReactNode } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
||||||
import { Browser } from "@wailsio/runtime";
|
import { Browser } from "@wailsio/runtime";
|
||||||
@@ -22,6 +22,7 @@ const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
|
|||||||
|
|
||||||
export function SettingsTroubleshooting() {
|
export function SettingsTroubleshooting() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const durationId = useId();
|
||||||
const {
|
const {
|
||||||
anonymize,
|
anonymize,
|
||||||
setAnonymize,
|
setAnonymize,
|
||||||
@@ -94,7 +95,7 @@ export function SettingsTroubleshooting() {
|
|||||||
/>
|
/>
|
||||||
<div className={"flex items-center gap-6 justify-between"}>
|
<div className={"flex items-center gap-6 justify-between"}>
|
||||||
<div className={"flex-1 max-w-md"}>
|
<div className={"flex-1 max-w-md"}>
|
||||||
<Label as={"div"} disabled={!capture}>
|
<Label htmlFor={durationId} disabled={!capture}>
|
||||||
{t("settings.troubleshooting.duration.label")}
|
{t("settings.troubleshooting.duration.label")}
|
||||||
</Label>
|
</Label>
|
||||||
<HelpText margin={false} disabled={!capture}>
|
<HelpText margin={false} disabled={!capture}>
|
||||||
@@ -103,6 +104,7 @@ export function SettingsTroubleshooting() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={"w-40 shrink-0"}>
|
<div className={"w-40 shrink-0"}>
|
||||||
<Input
|
<Input
|
||||||
|
id={durationId}
|
||||||
type={"number"}
|
type={"number"}
|
||||||
min={1}
|
min={1}
|
||||||
max={30}
|
max={30}
|
||||||
@@ -244,6 +246,7 @@ function DoneResult({
|
|||||||
<Input
|
<Input
|
||||||
value={result.path}
|
value={result.path}
|
||||||
readOnly
|
readOnly
|
||||||
|
aria-label={t("settings.troubleshooting.done.savedTitle")}
|
||||||
customSuffix={
|
customSuffix={
|
||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
@@ -251,7 +254,7 @@ function DoneResult({
|
|||||||
className={"pointer-events-auto hover:text-white transition-all"}
|
className={"pointer-events-auto hover:text-white transition-all"}
|
||||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||||
>
|
>
|
||||||
<FolderOpen size={16} />
|
<FolderOpen size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -259,6 +262,7 @@ function DoneResult({
|
|||||||
|
|
||||||
{uploadFailed && (
|
{uploadFailed && (
|
||||||
<div
|
<div
|
||||||
|
role="alert"
|
||||||
className={
|
className={
|
||||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
"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"}
|
className={"w-full"}
|
||||||
onClick={onRevealPath}
|
onClick={onRevealPath}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} />
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
{t("settings.troubleshooting.done.openFolder")}
|
{t("settings.troubleshooting.done.openFolder")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,5 +159,12 @@ export default function WelcomeDialog() {
|
|||||||
}
|
}
|
||||||
}, [initial, step, handleTrayContinue, handleManagementContinue]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
<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"}>
|
<DialogDescription align={"left"}>
|
||||||
{t("welcome.management.description")}
|
{t("welcome.management.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={"flex flex-col w-full gap-1"}>
|
<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>
|
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Update verfügbar"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Zu Profil \"{name}\" wechseln?"
|
"message": "Zu Profil \"{name}\" wechseln?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -479,6 +479,50 @@
|
|||||||
"message": "Update Available",
|
"message": "Update Available",
|
||||||
"description": "'More' menu item / badge shown when an update is 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": {
|
"profile.switch.title": {
|
||||||
"message": "Switch Profile to \"{name}\"?",
|
"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."
|
"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",
|
"message": "Debug Bundle Failed",
|
||||||
"description": "Error-dialog title when creating the debug bundle fails."
|
"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": {
|
"settings.tabs.general": {
|
||||||
"message": "General",
|
"message": "General",
|
||||||
"description": "Settings tab label: General. Keep short."
|
"description": "Settings tab label: General. Keep short."
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Actualización disponible"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "¿Cambiar el perfil a «{name}»?"
|
"message": "¿Cambiar el perfil a «{name}»?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Mise à jour disponible"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Basculer vers le profil « {name} » ?"
|
"message": "Basculer vers le profil « {name} » ?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Frissítés elérhető"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Aggiornamento disponibile"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Passare al profilo «{name}»?"
|
"message": "Passare al profilo «{name}»?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Atualização disponível"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Alternar perfil para \"{name}\"?"
|
"message": "Alternar perfil para \"{name}\"?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "Доступно обновление"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "Переключиться на профиль «{name}»?"
|
"message": "Переключиться на профиль «{name}»?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -359,6 +359,42 @@
|
|||||||
"header.menu.updateAvailable": {
|
"header.menu.updateAvailable": {
|
||||||
"message": "有可用更新"
|
"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": {
|
"profile.switch.title": {
|
||||||
"message": "切换到配置文件“{name}”?"
|
"message": "切换到配置文件“{name}”?"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user