mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 00:49:07 +02:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # client/proto/daemon.pb.go # management/internals/controllers/network_map/controller/controller.go # management/internals/controllers/network_map/nmaptest/runner.go # management/internals/shared/grpc/server.go # management/server/account.go # shared/management/proto/management.pb.go # upload-server/server/server.go
This commit is contained in:
@@ -10,7 +10,7 @@ Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`
|
||||
// Services
|
||||
import {
|
||||
Connection, Peers, ProfileSwitcher, Profiles,
|
||||
Settings, Networks, Forwarding, Debug, Update, WindowManager,
|
||||
Settings, Networks, Debug, Update, WindowManager,
|
||||
I18n, Preferences,
|
||||
} from "@bindings/services";
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
Profile, ProfileRef, ActiveProfile,
|
||||
Config, ConfigParams, SetConfigParams, Features,
|
||||
Network, SelectNetworksParams,
|
||||
ForwardingRule, PortInfo, PortRange,
|
||||
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
|
||||
DebugBundleParams, DebugBundleResult, LogLevel,
|
||||
UpdateResult, UpdateAvailable, UpdateProgress,
|
||||
@@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise<void>
|
||||
|
||||
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
|
||||
|
||||
## `Forwarding`
|
||||
|
||||
```ts
|
||||
Forwarding.List(): Promise<ForwardingRule[]>
|
||||
```
|
||||
|
||||
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
|
||||
|
||||
## `Debug`
|
||||
|
||||
```ts
|
||||
@@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
|
||||
|
||||
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
|
||||
|
||||
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
|
||||
|
||||
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
|
||||
|
||||
`PortRange`: `{ start, end: number }` (inclusive).
|
||||
|
||||
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
|
||||
|
||||
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
|
||||
|
||||
@@ -80,7 +80,7 @@ export const CopyToClipboard = ({
|
||||
aria-label={resolvedLabel}
|
||||
aria-live={"polite"}
|
||||
className={cn(
|
||||
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
|
||||
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
@@ -97,14 +97,14 @@ export const CopyToClipboard = ({
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"relative right-[1px] top-[2px] inline-flex shrink-0",
|
||||
"relative end-[1px] top-[2px] inline-flex shrink-0",
|
||||
iconAlignment === "left" ? "order-first" : "order-last",
|
||||
iconClassName,
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenu = (props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => {
|
||||
const dir = useDirection();
|
||||
return <DropdownMenuPrimitive.Root dir={dir} {...props} />;
|
||||
};
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
@@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 pe-2 ps-3 text-sm outline-none",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
inset && "ps-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
|
||||
<ChevronRight className={"ms-auto h-4 w-4 rtl:-scale-x-100"} aria-hidden={"true"} />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
@@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
|
||||
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
inset && "ps-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
@@ -134,7 +138,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-8 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
@@ -142,7 +146,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
|
||||
<span className={"absolute start-2 flex h-3.5 w-3.5 items-center justify-center"}>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className={"h-4 w-4"} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
@@ -159,19 +163,23 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"my-0.5 flex cursor-default select-none items-center gap-2 rounded-md px-2 py-2 outline-none",
|
||||
"text-xs font-semibold text-nb-gray-200 transition-colors",
|
||||
"data-[highlighted]:bg-nb-gray-850 data-[highlighted]:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
|
||||
{children}
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={"ms-auto flex w-4 shrink-0 items-center justify-center"}
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className={"h-2 w-2 fill-current"} />
|
||||
<Check size={14} className={"text-netbird"} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
@@ -186,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
|
||||
inset && "pl-8",
|
||||
inset && "ps-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -208,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
|
||||
className={cn("ms-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
|
||||
|
||||
@@ -21,6 +22,7 @@ const labelFor = (lang: Language): string =>
|
||||
: lang.displayName;
|
||||
|
||||
export function LanguagePicker() {
|
||||
const dir = useDirection();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -89,7 +91,11 @@ export function LanguagePicker() {
|
||||
tabIndex={0}
|
||||
disabled={busy || languages.length === 0}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
aria-label={t("settings.general.language.label")}
|
||||
aria-label={
|
||||
current
|
||||
? `${t("settings.general.language.label")}: ${labelFor(current)}`
|
||||
: t("settings.general.language.label")
|
||||
}
|
||||
aria-haspopup={"listbox"}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
@@ -108,7 +114,7 @@ export function LanguagePicker() {
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"flex-1 truncate text-left"}>
|
||||
<span className={"flex-1 truncate text-start"}>
|
||||
{current ? labelFor(current) : "—"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
@@ -164,7 +170,11 @@ export function LanguagePicker() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"-mx-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"max-h-64 px-1"}>
|
||||
<Command.List>
|
||||
<Command.Empty>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
@@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
||||
|
||||
export const ReadySignal = () => {
|
||||
const { isReady } = useStatus();
|
||||
const sent = useRef(false);
|
||||
const [params] = useSearchParams();
|
||||
const generation = params.get("gen") ?? "";
|
||||
const sent = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || sent.current) return;
|
||||
sent.current = true;
|
||||
void Events.Emit(EVENT_WINDOW_PAINTED);
|
||||
}, [isReady]);
|
||||
if (!isReady || sent.current === generation) return;
|
||||
sent.current = generation;
|
||||
void Events.Emit(EVENT_WINDOW_PAINTED, generation);
|
||||
}, [isReady, generation]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
|
||||
import { Select } from "@/components/inputs/Select";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
|
||||
@@ -25,16 +17,12 @@ export function ThemePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
|
||||
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
|
||||
const CurrentIcon = current.icon;
|
||||
|
||||
const select = async (value: string) => {
|
||||
const select = async (value: ThemePreference) => {
|
||||
if (busy || value === theme) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setTheme(value as ThemePreference);
|
||||
await setTheme(value);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
@@ -52,57 +40,17 @@ export function ThemePicker() {
|
||||
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.general.theme.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<CurrentIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"flex-1 truncate text-left"}>
|
||||
{t(current.labelKey)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"end"}
|
||||
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
|
||||
>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
|
||||
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"mr-2 shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Select
|
||||
value={theme}
|
||||
options={OPTIONS.map(({ value, icon, labelKey }) => ({
|
||||
value,
|
||||
icon,
|
||||
label: t(labelKey),
|
||||
}))}
|
||||
onChange={(v) => void select(v)}
|
||||
ariaLabel={t("settings.general.theme.label")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import * as RTooltip from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
type Props = {
|
||||
content: ReactNode;
|
||||
@@ -29,6 +30,8 @@ export const Tooltip = ({
|
||||
contentClassName,
|
||||
closeDelay = 0,
|
||||
}: Props) => {
|
||||
const dir = useDirection();
|
||||
const physicalSide = dir === "rtl" ? mirrorSide(side) : side;
|
||||
const [open, setOpen] = useState(false);
|
||||
const hoveringRef = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -73,7 +76,7 @@ export const Tooltip = ({
|
||||
</RTooltip.Trigger>
|
||||
<RTooltip.Portal>
|
||||
<RTooltip.Content
|
||||
side={side}
|
||||
side={physicalSide}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
@@ -96,3 +99,9 @@ export const Tooltip = ({
|
||||
</RTooltip.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function mirrorSide(side: Props["side"]): Props["side"] {
|
||||
if (side === "left") return "right";
|
||||
if (side === "right") return "left";
|
||||
return side;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@ type Props = {
|
||||
className?: string;
|
||||
tooltipContent?: ReactNode;
|
||||
delayDuration?: number;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
};
|
||||
|
||||
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
|
||||
export const TruncatedText = ({
|
||||
text,
|
||||
className,
|
||||
tooltipContent,
|
||||
delayDuration = 600,
|
||||
dir = "auto",
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
@@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration =
|
||||
}, [text]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={className}>
|
||||
<span ref={ref} dir={dir} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { type LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
|
||||
function VerticalTabsRoot({ className, ...props }, ref) {
|
||||
const dir = useDirection();
|
||||
return (
|
||||
<Tabs.Root
|
||||
ref={ref}
|
||||
dir={dir}
|
||||
orientation={"vertical"}
|
||||
className={cn("flex min-h-0 flex-1", className)}
|
||||
{...props}
|
||||
@@ -24,7 +27,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTab
|
||||
return (
|
||||
<Tabs.List
|
||||
ref={ref}
|
||||
className={cn("flex w-full flex-col gap-1 p-5 pr-0", className)}
|
||||
className={cn("flex w-full flex-col gap-1 p-5 pe-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -46,7 +49,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-start outline-none dark:border-0",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
@@ -60,7 +63,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
size={iconSize}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"ml-2 shrink-0 transition-colors duration-150",
|
||||
"ms-2 shrink-0 transition-colors duration-150",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
@@ -75,7 +78,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
{title}
|
||||
</span>
|
||||
{adornment && (
|
||||
<div aria-hidden={"true"} className={"ml-auto mr-2 shrink-0"}>
|
||||
<div aria-hidden={"true"} className={"me-2 ms-auto shrink-0"}>
|
||||
{adornment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,7 +147,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
tabIndex={0}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled}
|
||||
aria-disabled={loading || undefined}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
@@ -156,10 +157,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
border: border ? 1 : 0,
|
||||
size,
|
||||
}),
|
||||
loading && "pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (copy !== undefined) {
|
||||
void navigator.clipboard
|
||||
.writeText(copy)
|
||||
|
||||
@@ -14,6 +14,7 @@ type ConfirmModalProps = {
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
cancellable?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
@@ -26,11 +27,13 @@ export const ConfirmModal = ({
|
||||
cancelLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
cancellable,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedCancel = cancelLabel ?? t("common.cancel");
|
||||
const canCancel = cancellable ?? !busy;
|
||||
|
||||
const srTitle = typeof title === "string" ? title : undefined;
|
||||
const srDescription = typeof description === "string" ? description : undefined;
|
||||
@@ -39,7 +42,7 @@ export const ConfirmModal = ({
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && !busy) onCancel();
|
||||
if (!next && canCancel) onCancel();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content
|
||||
@@ -51,9 +54,9 @@ export const ConfirmModal = ({
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex flex-col gap-5 px-5"}>
|
||||
<div className={"flex flex-col gap-1 pl-1"}>
|
||||
<DialogHeading align={"left"}>{title}</DialogHeading>
|
||||
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
|
||||
<div className={"flex flex-col gap-1 ps-1"}>
|
||||
<DialogHeading align={"start"}>{title}</DialogHeading>
|
||||
<DialogDescription align={"start"} className={"whitespace-pre-line"}>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
@@ -62,7 +65,7 @@ export const ConfirmModal = ({
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
disabled={!canCancel}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{resolvedCancel}
|
||||
@@ -71,7 +74,7 @@ export const ConfirmModal = ({
|
||||
autoFocus
|
||||
variant={danger ? "danger" : "primary"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
|
||||
"absolute end-3 top-3 z-10 rounded-md p-3 transition-colors",
|
||||
"text-nb-gray-300 hover:text-nb-gray-100",
|
||||
"focus:outline-none disabled:pointer-events-none",
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
type DialogAlign = "start" | "center" | "end";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
start: "text-start",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
end: "text-end",
|
||||
};
|
||||
|
||||
type DialogDescriptionProps = {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type DialogAlign = "left" | "center" | "right";
|
||||
type DialogAlign = "start" | "center" | "end";
|
||||
|
||||
const alignClass: Record<DialogAlign, string> = {
|
||||
left: "text-left",
|
||||
start: "text-start",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
end: "text-end",
|
||||
};
|
||||
|
||||
type DialogHeadingProps = {
|
||||
|
||||
@@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => {
|
||||
{clientVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
<span dir={"auto"} className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
@@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => {
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
<span dir={"auto"} className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -86,13 +86,13 @@ function buildInputClassName(
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
|
||||
opts.hasSuffix && "!pr-9",
|
||||
opts.hasIcon && "!pl-10",
|
||||
opts.hasCustomPrefix && "!rounded-s-none !border-s-0",
|
||||
opts.hasSuffix && "!pe-9",
|
||||
opts.hasIcon && "!ps-10",
|
||||
"border",
|
||||
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
|
||||
opts.showStepper &&
|
||||
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||
"!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||
opts.className,
|
||||
);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function InputAffix({
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"flex h-[40px] w-auto rounded-s-md bg-white px-3 py-2 text-sm",
|
||||
"items-center whitespace-nowrap border",
|
||||
disabled && "opacity-40",
|
||||
className,
|
||||
@@ -122,7 +122,7 @@ function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
"absolute start-0 top-0 flex h-full items-center ps-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ function InputSuffixSlot({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
"pointer-events-none absolute end-0 top-0 flex h-full select-none items-center pe-3 text-xs leading-[0] dark:text-nb-gray-300",
|
||||
disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
@@ -157,7 +157,7 @@ function NumberStepper({
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"rounded-e-md border border-s-0",
|
||||
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
@@ -226,6 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
showPasswordToggle = false,
|
||||
copy = false,
|
||||
id,
|
||||
dir,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -336,7 +337,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
return (
|
||||
<div className={"flex w-full min-w-0 flex-col"}>
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
|
||||
<div dir={dir} className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<InputAffix
|
||||
content={customPrefix}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type SelectOption<T extends string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
type SelectProps<T extends string> = {
|
||||
value: T;
|
||||
options: SelectOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Select<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
className,
|
||||
}: SelectProps<T>) {
|
||||
const isFocusVisible = useFocusVisible();
|
||||
const current = options.find((o) => o.value === value) ?? options[0];
|
||||
const CurrentIcon = current?.icon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
aria-label={current ? `${ariaLabel}: ${current.label}` : ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{CurrentIcon && (
|
||||
<CurrentIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
)}
|
||||
<span className={"flex-1 truncate text-start"}>{current?.label ?? "—"}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
className={
|
||||
"w-[var(--radix-dropdown-menu-trigger-width)] border-nb-gray-850 bg-nb-gray-920"
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
|
||||
{options.map(({ value: optionValue, label, icon: Icon }) => (
|
||||
<DropdownMenuRadioItem key={optionValue} value={optionValue}>
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
)}
|
||||
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default function FancyToggleSwitch({
|
||||
role={"status"}
|
||||
aria-busy={"true"}
|
||||
aria-live={"polite"}
|
||||
className={cn("inline-block w-full text-left", className)}
|
||||
className={cn("inline-block w-full text-start", className)}
|
||||
>
|
||||
<div className={"flex justify-between gap-10"}>
|
||||
<div className={cn(textWrapperClassName)}>
|
||||
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
|
||||
</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div className={"mt-2 pe-1"}>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
@@ -73,7 +73,7 @@ export default function FancyToggleSwitch({
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
"relative z-[1] cursor-default transition-all duration-300",
|
||||
"inline-block w-full text-left",
|
||||
"inline-block w-full text-start",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
)}
|
||||
@@ -87,7 +87,7 @@ export default function FancyToggleSwitch({
|
||||
<span id={descriptionId}>{helpText}</span>
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div className={"mt-2 pe-1"}>
|
||||
<ToggleSwitch
|
||||
id={switchId}
|
||||
checked={value}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
type SwitchItemGroupContextValue = {
|
||||
value: string;
|
||||
@@ -37,11 +38,13 @@ export const SwitchItemGroup = ({
|
||||
"aria-labelledby": ariaLabelledBy,
|
||||
}: Props) => {
|
||||
const layoutId = useId();
|
||||
const dir = useDirection();
|
||||
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
|
||||
|
||||
return (
|
||||
<SwitchItemGroupContext.Provider value={contextValue}>
|
||||
<RadioGroup.Root
|
||||
dir={dir}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -36,9 +36,9 @@ const switchVariants = cva("", {
|
||||
},
|
||||
"thumb-size": {
|
||||
default:
|
||||
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
|
||||
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
|
||||
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-5",
|
||||
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-[17px]",
|
||||
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px] rtl:data-[state=checked]:-translate-x-[31px] rtl:data-[state=unchecked]:-translate-x-[1px]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,28 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
|
||||
import i18next from "@/lib/i18n";
|
||||
|
||||
// Nothing on the daemon path carries a deadline, so a hung call would leave the
|
||||
// modal spinning with no way out. Cancel comes back once the wait stops looking
|
||||
// normal, and the wait is abandoned entirely at the deadline.
|
||||
const CANCELLABLE_AFTER_MS = 15_000;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
const withTimeout = async (action: () => Promise<unknown>) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const expiry = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(i18next.t("error.daemon_unreachable"))),
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([action(), expiry]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title: ReactNode;
|
||||
@@ -15,6 +37,7 @@ export type ConfirmOptions = {
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
type DialogContextValue = {
|
||||
@@ -23,23 +46,50 @@ type DialogContextValue = {
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
type Settler = { resolve: (result: boolean) => void; reject: (reason: unknown) => void };
|
||||
|
||||
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stalled, setStalled] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<((result: boolean) => void) | null>(null);
|
||||
const resolverRef = useRef<Settler | null>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions) => {
|
||||
setOptions(opts);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
resolverRef.current = { resolve, reject };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const settle = (result: boolean) => {
|
||||
resolverRef.current?.(result);
|
||||
const take = (expected?: Settler | null) => {
|
||||
const settler = resolverRef.current;
|
||||
if (expected && settler !== expected) return null;
|
||||
resolverRef.current = null;
|
||||
setBusy(false);
|
||||
setStalled(false);
|
||||
setOpen(false);
|
||||
return settler;
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const action = options?.onConfirm;
|
||||
if (!action) {
|
||||
take()?.resolve(true);
|
||||
return;
|
||||
}
|
||||
const dispatched = resolverRef.current;
|
||||
setBusy(true);
|
||||
const stallTimer = setTimeout(() => setStalled(true), CANCELLABLE_AFTER_MS);
|
||||
try {
|
||||
await withTimeout(action);
|
||||
take(dispatched)?.resolve(true);
|
||||
} catch (e) {
|
||||
take(dispatched)?.reject(e);
|
||||
} finally {
|
||||
clearTimeout(stallTimer);
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
|
||||
@@ -54,8 +104,10 @@ export function DialogProvider({ children }: Readonly<{ children: ReactNode }>)
|
||||
confirmLabel={options?.confirmLabel ?? ""}
|
||||
cancelLabel={options?.cancelLabel}
|
||||
danger={options?.danger}
|
||||
onConfirm={() => settle(true)}
|
||||
onCancel={() => settle(false)}
|
||||
busy={busy}
|
||||
cancellable={!busy || stalled}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
onCancel={() => take()?.resolve(false)}
|
||||
/>
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
|
||||
@@ -96,6 +96,11 @@ body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
}
|
||||
|
||||
[dir="rtl"] :is(.font-mono, code, kbd):not([dir]) {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.wails-draggable {
|
||||
--wails-draggable: drag;
|
||||
cursor: default;
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events, Window } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isLinux } from "@/lib/platform";
|
||||
|
||||
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
||||
|
||||
// Sizes the current Wails window to the measured content height (keeping `width`),
|
||||
// then shows it. Re-applies on content resize and language change.
|
||||
// then reports it as painted so Go shows it. Re-applies on content resize and language change.
|
||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [params] = useSearchParams();
|
||||
const generation = params.get("gen") ?? "";
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let shown = false;
|
||||
let painted = false;
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
const showOnce = () => {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
Window.Show().catch(() => {});
|
||||
Window.Focus().catch(() => {});
|
||||
const paintedOnce = () => {
|
||||
if (painted) return;
|
||||
painted = true;
|
||||
Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {});
|
||||
};
|
||||
const apply = async () => {
|
||||
if (!ready) return;
|
||||
@@ -33,7 +37,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
||||
await Window.SetMaxSize(width, targetH);
|
||||
}
|
||||
await Window.SetSize(width, targetH);
|
||||
showOnce();
|
||||
paintedOnce();
|
||||
} catch {
|
||||
// window gone / not ready — ignore
|
||||
}
|
||||
@@ -55,6 +59,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
||||
cancelAnimationFrame(raf2);
|
||||
i18next.off("languageChanged", scheduleApply);
|
||||
};
|
||||
}, [width, ready]);
|
||||
}, [width, ready, generation]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useDirection(): "ltr" | "rtl" {
|
||||
const { i18n } = useTranslation();
|
||||
return i18n.dir(i18n.language);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -15,17 +16,19 @@ const PANEL_TRANSITION = {
|
||||
};
|
||||
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
|
||||
const dir = useDirection();
|
||||
const parallaxX = dir === "rtl" ? 48 : -48;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-ee-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: overlayOpen ? -48 : 0 }}
|
||||
animate={{ x: overlayOpen ? parallaxX : 0 }}
|
||||
transition={PANEL_TRANSITION}
|
||||
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
|
||||
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
|
||||
|
||||
@@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An empty persisted language code is the Go-side signal for first run.
|
||||
// Only locales listed in _index.json are shipped. A bundle on disk without an
|
||||
// _index.json row (e.g. a new Crowdin language not released yet) must not be
|
||||
// auto-detected or loaded, matching the Go side, which rejects saving it.
|
||||
async function shippedLanguages(): Promise<string[]> {
|
||||
const onDisk = Object.keys(resources);
|
||||
try {
|
||||
const listed = new Set<string>((await I18n.Languages()).map((l) => l.code));
|
||||
return onDisk.filter((code) => listed.has(code));
|
||||
} catch (e) {
|
||||
console.warn("load shipped languages failed, using all bundled locales", e);
|
||||
return onDisk;
|
||||
}
|
||||
}
|
||||
|
||||
// An empty persisted language code is the Go-side signal for first run. A
|
||||
// persisted code that is no longer shipped is treated the same way.
|
||||
export async function initI18n(): Promise<void> {
|
||||
const available = Object.keys(resources);
|
||||
const available = await shippedLanguages();
|
||||
let language = "en";
|
||||
let firstRun = false;
|
||||
try {
|
||||
const prefs = await Preferences.Get();
|
||||
if (prefs?.language) {
|
||||
if (prefs?.language && available.includes(prefs.language)) {
|
||||
language = prefs.language;
|
||||
} else {
|
||||
firstRun = true;
|
||||
@@ -68,7 +83,7 @@ export async function initI18n(): Promise<void> {
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
resources,
|
||||
resources: Object.fromEntries(available.map((code) => [code, resources[code]])),
|
||||
interpolation: {
|
||||
prefix: "{",
|
||||
suffix: "}",
|
||||
@@ -93,6 +108,7 @@ export async function initI18n(): Promise<void> {
|
||||
function syncDocumentLang() {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = i18next.language;
|
||||
document.documentElement.dir = i18next.dir(i18next.language);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function ErrorDialog() {
|
||||
wrap
|
||||
variant={"bright"}
|
||||
className={
|
||||
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
|
||||
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
@@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
const [params] = useSearchParams();
|
||||
const uri = params.get("uri") ?? "";
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const openedRef = useRef(false);
|
||||
|
||||
const reportOpenFailure = useCallback(
|
||||
(e: unknown) => {
|
||||
@@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
[t],
|
||||
);
|
||||
|
||||
// Open the browser only after mount, or it lands on top of the still-hidden popup.
|
||||
useEffect(() => {
|
||||
if (!uri || openedRef.current) return;
|
||||
openedRef.current = true;
|
||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
||||
}, [uri, reportOpenFailure]);
|
||||
|
||||
const tryAgain = useCallback(() => {
|
||||
if (!uri) return;
|
||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
||||
|
||||
@@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => {
|
||||
tabIndex={show && fqdn ? 0 : -1}
|
||||
className={cn(
|
||||
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
"relative start-[0.55rem]",
|
||||
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<TruncatedText
|
||||
text={shortenDns(fqdn) || " "}
|
||||
dir={"ltr"}
|
||||
className={
|
||||
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
|
||||
}
|
||||
@@ -298,7 +299,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
tabIndex={show && ip ? 0 : -1}
|
||||
className={cn(
|
||||
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
"relative start-[0.55rem]",
|
||||
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
@@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"absolute -right-5 top-1/2 -translate-y-1/2",
|
||||
"absolute -end-5 top-1/2 -translate-y-1/2",
|
||||
"shrink-0 text-nb-gray-300 transition-colors",
|
||||
"group-hover:text-nb-gray-200",
|
||||
"group-data-[state=open]:text-nb-gray-200",
|
||||
@@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
aria-label={`${t("common.copy")} ${value}`}
|
||||
className={cn(
|
||||
"group/iprow relative flex items-center justify-between gap-3",
|
||||
"rounded-md px-2 py-1.5 text-left",
|
||||
"rounded-md px-2 py-1.5 text-start",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
|
||||
@@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const MainExitNodeSwitcher = () => {
|
||||
const dir = useDirection();
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { exitNodes, toggleExitNode } = useNetworks();
|
||||
@@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => {
|
||||
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
|
||||
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
|
||||
{hasAny && (
|
||||
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"-mx-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"max-h-72 px-1"}>
|
||||
{exitNodes.map((n) => (
|
||||
<ExitNodeRow
|
||||
@@ -155,7 +161,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
@@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
|
||||
value={NONE_VALUE}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 pr-3",
|
||||
"flex items-center gap-2 px-2 py-2 pe-3",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
@@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
|
||||
value={id}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-2 pr-3",
|
||||
"flex items-center gap-2 px-2 py-2 pe-3",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,8 @@ import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
import { isWindows } from "@/lib/platform.ts";
|
||||
import { isMacOS, isWindows } from "@/lib/platform.ts";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
@@ -35,6 +36,8 @@ export const MainHeader = () => {
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const dir = useDirection();
|
||||
const pinSettingsRight = isMacOS();
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
@@ -83,7 +86,7 @@ export const MainHeader = () => {
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"end"}
|
||||
align={pinSettingsRight && dir === "rtl" ? "start" : "end"}
|
||||
sideOffset={8}
|
||||
className={
|
||||
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
|
||||
@@ -138,7 +141,7 @@ export const MainHeader = () => {
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={
|
||||
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
||||
"pointer-events-none absolute end-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
||||
}
|
||||
>
|
||||
<span
|
||||
@@ -168,10 +171,17 @@ export const MainHeader = () => {
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
|
||||
<div className={"ml-4 flex justify-center rtl:ml-1"}>{profileSlot}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2",
|
||||
pinSettingsRight ? "right-[1.3rem]" : "end-[1.3rem]",
|
||||
)}
|
||||
>
|
||||
{settingsSlot}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const MainBody = () => {
|
||||
>
|
||||
<MainConnectionStatusSwitch />
|
||||
{!features.disableNetworks && (
|
||||
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
|
||||
<div className={"wails-no-draggable absolute inset-x-5 bottom-5"}>
|
||||
<MainExitNodeSwitcher />
|
||||
</div>
|
||||
)}
|
||||
@@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => {
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
className={"m-5 ml-0"}
|
||||
className={"m-5 ms-0"}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
type TabEntry = {
|
||||
value: NavSection;
|
||||
@@ -48,6 +49,10 @@ export const Navigation = () => {
|
||||
requestAnimationFrame(() => tabRefs.current[value]?.focus());
|
||||
};
|
||||
|
||||
const dir = useDirection();
|
||||
const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
|
||||
const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const enabled = tabs.filter((t) => isConnected || t.value === section);
|
||||
if (enabled.length < 2) return;
|
||||
@@ -55,10 +60,10 @@ export const Navigation = () => {
|
||||
if (currentIndex === -1) return;
|
||||
let nextIndex: number;
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case forwardKey:
|
||||
nextIndex = (currentIndex + 1) % enabled.length;
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case backwardKey:
|
||||
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
|
||||
break;
|
||||
case "Home":
|
||||
@@ -106,8 +111,8 @@ export const Navigation = () => {
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3.5",
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
isFirst && "rounded-ss-xl",
|
||||
isLast && "rounded-se-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
|
||||
@@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
|
||||
const INVALID_PREFIX = "invalid Prefix";
|
||||
@@ -70,6 +71,7 @@ const buildOverlapMap = (
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const dir = useDirection();
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
@@ -175,7 +177,11 @@ export const Networks = () => {
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && (
|
||||
<NetworksList
|
||||
@@ -343,7 +349,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-9 ps-6",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
@@ -423,6 +429,7 @@ const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
|
||||
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
|
||||
<TruncatedText
|
||||
text={network.range}
|
||||
dir={"ltr"}
|
||||
className={
|
||||
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
|
||||
}
|
||||
@@ -463,7 +470,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
|
||||
contentClassName={cn(
|
||||
"max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
"p-2 pe-4",
|
||||
)}
|
||||
>
|
||||
{span}
|
||||
@@ -508,7 +515,9 @@ type ToggleProps = {
|
||||
};
|
||||
|
||||
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
|
||||
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
|
||||
const checkedTranslate = checked
|
||||
? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]"
|
||||
: "translate-x-0.5 rtl:-translate-x-0.5";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -521,7 +530,7 @@ const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
mixed ? "translate-x-2.5" : checkedTranslate,
|
||||
mixed ? "translate-x-2.5 rtl:-translate-x-2.5" : checkedTranslate,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { peerStatusLabelKey } from "./Peers";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.32,
|
||||
@@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, setSelected } = usePeerDetail();
|
||||
const { status, refresh } = useStatus();
|
||||
const dir = useDirection();
|
||||
const offscreenX = dir === "rtl" ? "-100%" : "100%";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
@@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
|
||||
@@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
};
|
||||
globalThis.addEventListener("keydown", onKey);
|
||||
return () => globalThis.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
}, [selected, setSelected, dir]);
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
aria-modal={"true"}
|
||||
aria-labelledby={"nb-peer-detail-title"}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
initial={{ x: "100%" }}
|
||||
initial={{ x: offscreenX }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
exit={{ x: offscreenX }}
|
||||
transition={transition}
|
||||
style={{ willChange: "transform" }}
|
||||
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
|
||||
@@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<ArrowLeftIcon size={16} aria-hidden={"true"} />
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"rtl:-scale-x-100"}
|
||||
/>
|
||||
</button>
|
||||
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
|
||||
<span
|
||||
@@ -248,7 +255,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerDetails peer={selected} now={now} />
|
||||
</ScrollArea.Viewport>
|
||||
@@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
"inline-flex shrink-0 items-center gap-1 rounded",
|
||||
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
|
||||
"border border-nb-gray-900",
|
||||
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
|
||||
"py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
@@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => {
|
||||
aria-label={`${t("common.copy")} ${value}`}
|
||||
className={cn(
|
||||
"group/resourcerow 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-start",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
@@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => {
|
||||
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
|
||||
<TruncatedText
|
||||
text={value}
|
||||
dir={mono ? "ltr" : "auto"}
|
||||
className={cn(
|
||||
"inline-block min-w-0 max-w-[260px] truncate align-middle",
|
||||
mono && "font-mono",
|
||||
@@ -564,7 +576,7 @@ const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 pl-8 text-right",
|
||||
"min-w-0 flex-1 ps-8 text-end",
|
||||
"font-medium text-nb-gray-350",
|
||||
"flex items-center justify-end",
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
<span>
|
||||
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
|
||||
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
|
||||
{filters.map((f) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
import { PeerFilters, type StatusFilter } from "./PeerFilters";
|
||||
|
||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
@@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => {
|
||||
};
|
||||
|
||||
export const Peers = () => {
|
||||
const dir = useDirection();
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -135,7 +137,11 @@ export const Peers = () => {
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
</ScrollArea.Viewport>
|
||||
@@ -167,6 +173,7 @@ type PeersListProps = {
|
||||
|
||||
const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
const dir = useDirection();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
@@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
e.preventDefault();
|
||||
focusRow(Math.max(index - 1, 0));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case dir === "rtl" ? "ArrowLeft" : "ArrowRight":
|
||||
e.preventDefault();
|
||||
setSelected(data[index]);
|
||||
break;
|
||||
@@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const ctx = useMemo<PeerRowContext>(
|
||||
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, setSelected],
|
||||
[data, setSelected, dir],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-4 ps-6",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
@@ -344,7 +351,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
|
||||
"pointer-events-none relative shrink-0 self-center text-nb-gray-300 rtl:-scale-x-100",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={"flex flex-col gap-6 px-7"}>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div className={"pl-1"}>
|
||||
<div className={"ps-1"}>
|
||||
<Label htmlFor={nameId} className={"mb-0.5"}>
|
||||
{t("profile.dialog.nameLabel")}
|
||||
</Label>
|
||||
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
|
||||
|
||||
{!managedManagementUrl && (
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div className={"pl-1"}>
|
||||
<div className={"ps-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<Input
|
||||
id={urlId}
|
||||
dir={"ltr"}
|
||||
ref={urlRef}
|
||||
aria-label={t("settings.general.management.label")}
|
||||
placeholder={t(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -19,6 +20,7 @@ type ProfileDropdownProps = {
|
||||
const MANAGE_VALUE = "__manage_profiles__";
|
||||
|
||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const dir = useDirection();
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
type={"auto"}
|
||||
className={"-mx-1 overflow-hidden"}
|
||||
>
|
||||
@@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
value={profile.id}
|
||||
onSelect={() => onSelect(profile.id)}
|
||||
className={cn(
|
||||
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
|
||||
"flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1",
|
||||
"cursor-default rounded-md text-sm outline-none",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
showEmail ? "items-start" : "items-center",
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ProfilesTab() {
|
||||
return items;
|
||||
}, [profiles, activeProfileId]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
const guarded = async (title: string, fn: () => Promise<unknown>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -115,14 +115,15 @@ export function ProfilesTab() {
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (id === DEFAULT_PROFILE_ID) return;
|
||||
const ok = await confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
description: t("profile.delete.message", { name }),
|
||||
confirmLabel: t("common.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
|
||||
await guarded(i18next.t("profile.error.deleteTitle"), () =>
|
||||
confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
description: t("profile.delete.message", { name }),
|
||||
confirmLabel: t("common.delete"),
|
||||
danger: true,
|
||||
onConfirm: () => removeProfile(id),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
@@ -439,7 +440,7 @@ const ProfileRow = ({
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"shrink-0 text-right"}>
|
||||
<td className={"shrink-0 text-end"}>
|
||||
<RowActions
|
||||
canSwitch={!isActive}
|
||||
canDeregister={!!profile.email}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function SettingsAbout() {
|
||||
{daemonVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
<span dir={"auto"} className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
@@ -124,7 +124,7 @@ export function SettingsAbout() {
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
<span dir={"auto"} className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
|
||||
const scratch = new Uint32Array(1);
|
||||
|
||||
function random() {
|
||||
crypto.getRandomValues(scratch);
|
||||
return scratch[0] / 2 ** 32;
|
||||
}
|
||||
|
||||
type Mask = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cells: Uint8Array;
|
||||
seeds: Uint8Array;
|
||||
glow: Float32Array;
|
||||
};
|
||||
|
||||
export function useAccentTrigger() {
|
||||
const clicksRef = useRef(0);
|
||||
@@ -50,24 +66,45 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const chars = "DRIBTENMAET".split("").reverse().join("");
|
||||
|
||||
let disposed = false;
|
||||
let mask: Mask | null = null;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
let columns = 0;
|
||||
let drops: number[] = [];
|
||||
let latestBuild = 0;
|
||||
let rebuild: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth * dpr;
|
||||
canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = `${window.innerWidth}px`;
|
||||
canvas.style.height = `${window.innerHeight}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const next = Math.floor(window.innerWidth / 15);
|
||||
if (next !== columns) {
|
||||
columns = next;
|
||||
drops = Array.from({ length: columns }, () => random() * -60);
|
||||
mask = null;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(rebuild);
|
||||
rebuild = globalThis.setTimeout(() => {
|
||||
const build = ++latestBuild;
|
||||
void buildMask().then((m) => {
|
||||
if (!disposed && build === latestBuild) mask = m;
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const chars = "TEAMNETBIRD";
|
||||
const fontSize = 16;
|
||||
const columns = Math.floor(window.innerWidth / fontSize);
|
||||
const drops = Array.from({ length: columns }, () => Math.random() * -50);
|
||||
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
let frame = 0;
|
||||
const draw = (t: number) => {
|
||||
if (t - last > 50) {
|
||||
last = t;
|
||||
@@ -77,18 +114,26 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.font = `${fontSize}px ui-monospace, monospace`;
|
||||
ctx.fillStyle = "#f68330";
|
||||
ctx.font = "15px ui-monospace, monospace";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = "rgba(246, 131, 48, 0.5)";
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const ch = chars[Math.floor(Math.random() * chars.length)];
|
||||
const y = drops[i] * fontSize;
|
||||
ctx.fillText(ch, i * fontSize, y);
|
||||
if (y > window.innerHeight && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
const ch = chars[Math.floor(random() * chars.length)];
|
||||
const y = drops[i] * 15;
|
||||
ctx.fillText(ch, i * 15, y);
|
||||
|
||||
igniteTrail(mask, i, Math.floor(drops[i]));
|
||||
|
||||
if (y > window.innerHeight && random() > 0.86) {
|
||||
drops[i] = random() * -12;
|
||||
}
|
||||
drops[i]++;
|
||||
}
|
||||
|
||||
drawGlow(ctx, mask, frame, chars);
|
||||
frame++;
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
@@ -100,8 +145,10 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
globalThis.clearTimeout(timeout);
|
||||
globalThis.clearTimeout(rebuild);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [onDone]);
|
||||
@@ -114,3 +161,103 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function igniteTrail(mask: Mask | null, col: number, row: number) {
|
||||
if (!mask || col < 0 || col >= mask.cols) return;
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const r = row - k;
|
||||
if (r < 0 || r >= mask.rows) continue;
|
||||
const idx = r * mask.cols + col;
|
||||
if (mask.cells[idx] === 0) continue;
|
||||
const heat = 1 - k / 6;
|
||||
if (heat > mask.glow[idx]) mask.glow[idx] = heat;
|
||||
}
|
||||
}
|
||||
|
||||
function drawGlow(ctx: CanvasRenderingContext2D, mask: Mask | null, frame: number, chars: string) {
|
||||
if (!mask) return;
|
||||
|
||||
for (let idx = 0; idx < mask.cells.length; idx++) {
|
||||
const heat = fade(mask, idx);
|
||||
if (heat === 0) continue;
|
||||
|
||||
const seed = mask.seeds[idx];
|
||||
const core = mask.cells[idx] === 2;
|
||||
|
||||
ctx.shadowColor = core ? "#f05252" : "#f68330";
|
||||
ctx.shadowBlur = 10 * heat;
|
||||
ctx.fillStyle = core ? `rgba(255, 226, 210, ${heat})` : `rgba(255, 255, 255, ${heat})`;
|
||||
ctx.fillText(
|
||||
chars[(seed + Math.floor(frame / (3 + (seed % 5)))) % chars.length],
|
||||
(idx % mask.cols) * 15,
|
||||
Math.floor(idx / mask.cols) * 15,
|
||||
);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function fade(mask: Mask, idx: number) {
|
||||
if (mask.cells[idx] === 0) return 0;
|
||||
|
||||
const heat = mask.glow[idx];
|
||||
if (heat <= 0.02) {
|
||||
mask.glow[idx] = 0;
|
||||
return 0;
|
||||
}
|
||||
mask.glow[idx] = heat * 0.94;
|
||||
return heat;
|
||||
}
|
||||
|
||||
function loadLogo() {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = netbirdLogo;
|
||||
});
|
||||
}
|
||||
|
||||
async function buildMask(): Promise<Mask | null> {
|
||||
const cols = Math.floor(window.innerWidth / 15);
|
||||
const rows = Math.ceil(window.innerHeight / 15);
|
||||
if (cols <= 0 || rows <= 0) return null;
|
||||
|
||||
let img: HTMLImageElement;
|
||||
try {
|
||||
img = await loadLogo();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const off = document.createElement("canvas");
|
||||
off.width = cols;
|
||||
off.height = rows;
|
||||
const offCtx = off.getContext("2d", { willReadFrequently: true });
|
||||
if (!offCtx) return null;
|
||||
|
||||
const aspect = (img.naturalWidth || 31) / (img.naturalHeight || 23);
|
||||
let w = cols * 0.8;
|
||||
let h = w / aspect;
|
||||
if (h > rows * 0.8) {
|
||||
h = rows * 0.8;
|
||||
w = h * aspect;
|
||||
}
|
||||
|
||||
offCtx.imageSmoothingEnabled = false;
|
||||
offCtx.drawImage(img, (cols - w) / 2, (rows - h) / 2, w, h);
|
||||
|
||||
const { data } = offCtx.getImageData(0, 0, cols, rows);
|
||||
const cells = new Uint8Array(cols * rows);
|
||||
const seeds = new Uint8Array(cols * rows);
|
||||
const glow = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
seeds[i] = Math.floor(random() * 251);
|
||||
const alpha = data[i * 4 + 3];
|
||||
if (alpha < 64) continue;
|
||||
const r = data[i * 4];
|
||||
const g = data[i * 4 + 1];
|
||||
const b = data[i * 4 + 2];
|
||||
cells[i] = r > 180 && g < 130 && b < 130 && g <= b + 24 ? 2 : 1;
|
||||
}
|
||||
return { cols, rows, cells, seeds, glow };
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export function SettingsAdvanced() {
|
||||
<SectionGroup title={t("settings.advanced.section.interface")}>
|
||||
<Input
|
||||
label={t("settings.advanced.interfaceName.label")}
|
||||
dir={"ltr"}
|
||||
value={values.interfaceName}
|
||||
error={errors.interfaceName}
|
||||
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
|
||||
@@ -151,6 +152,7 @@ export function SettingsAdvanced() {
|
||||
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
dir={"ltr"}
|
||||
showPasswordToggle={pskInputValue !== PSK_MASK}
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={pskInputValue}
|
||||
|
||||
@@ -88,6 +88,7 @@ export function SettingsGeneral() {
|
||||
<div className={"mt-2 flex items-start gap-3"}>
|
||||
<Input
|
||||
id={managementUrlId}
|
||||
dir={"ltr"}
|
||||
ref={inputRef}
|
||||
value={displayUrl}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useDirection } from "@/hooks/useDirection";
|
||||
|
||||
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
|
||||
|
||||
@@ -43,6 +44,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const dir = useDirection();
|
||||
const location = useLocation();
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const { mdm, features } = useRestrictions();
|
||||
@@ -94,6 +96,7 @@ export const SettingsPage = () => {
|
||||
<AutostartSettingsProvider>
|
||||
<SettingsProvider>
|
||||
<ScrollArea.Root
|
||||
dir={dir}
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const SectionGroup = ({
|
||||
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div className={"absolute bottom-0 start-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
|
||||
import {
|
||||
CircleCheckBig,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Loader2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
@@ -8,20 +17,13 @@ import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { Select } from "@/components/inputs/Select";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
@@ -29,6 +31,12 @@ import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSect
|
||||
|
||||
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
|
||||
|
||||
const ANONYMIZE_LEVELS: { value: AnonymizeLevel; icon: LucideIcon }[] = [
|
||||
{ value: "none", icon: ShieldOff },
|
||||
{ value: "default", icon: Shield },
|
||||
{ value: "strict", icon: ShieldCheck },
|
||||
];
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const durationId = useId();
|
||||
@@ -89,44 +97,16 @@ export function SettingsTroubleshooting() {
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={anonymizeLevel}
|
||||
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
|
||||
>
|
||||
<DropdownMenuRadioItem value={"none"}>
|
||||
{t("settings.troubleshooting.anonymize.none")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"default"}>
|
||||
{t("settings.troubleshooting.anonymize.default")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"strict"}>
|
||||
{t("settings.troubleshooting.anonymize.strict")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Select
|
||||
value={anonymizeLevel}
|
||||
options={ANONYMIZE_LEVELS.map(({ value, icon }) => ({
|
||||
value,
|
||||
icon,
|
||||
label: t(`settings.troubleshooting.anonymize.${value}`),
|
||||
}))}
|
||||
onChange={setAnonymizeLevel}
|
||||
ariaLabel={t("settings.troubleshooting.anonymize.label")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
@@ -318,10 +298,11 @@ function DoneResult({
|
||||
</div>
|
||||
|
||||
<div className={"flex w-full max-w-sm flex-col gap-3"}>
|
||||
{showKey && <Input value={result.uploadedKey} readOnly copy />}
|
||||
{showKey && <Input dir={"ltr"} value={result.uploadedKey} readOnly copy />}
|
||||
|
||||
{result.path && !showKey && (
|
||||
<Input
|
||||
dir={"ltr"}
|
||||
value={result.path}
|
||||
readOnly
|
||||
aria-label={t("settings.troubleshooting.done.savedTitle")}
|
||||
|
||||
@@ -90,10 +90,10 @@ export function WelcomeStepManagement({
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
||||
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
|
||||
<DialogHeading id={"nb-welcome-management-title"} align={"start"}>
|
||||
{t("welcome.management.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>
|
||||
<DialogDescription align={"start"}>
|
||||
{t("welcome.management.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
|
||||
</div>
|
||||
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"wails-no-draggable w-full text-left"}>
|
||||
<div className={"wails-no-draggable w-full text-start"}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
dir={"ltr"}
|
||||
placeholder={t("welcome.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
|
||||
@@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
</div>
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"start"}>
|
||||
{t(titleKey)}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||
<DialogDescription align={"start"}>{t(descriptionKey)}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
Reference in New Issue
Block a user