Files
netbird/client/ui/frontend/src/components/LanguagePicker.tsx
T
Zoltan Papp 53d43980ad fix(ui): keep main window closable after a native dialog on Windows (#6319)
A native Windows MessageBox attached to a parent window disables that
window (WS_DISABLED) for its lifetime and re-enables it on dismissal.
When the parent is the main window — whose WindowClosing hook hides
instead of closes — the enable/hide sequence races and leaves the window
unable to process its close (X) button afterwards, so e.g. a rejected
login error dialog left the main window stuck open.

Route all native dialogs through src/lib/dialogs.ts, which forces
Detached: true on Windows (NULL owner, no window ever disabled) and is a
no-op on macOS/Linux (keeps the attached sheet-style presentation).
2026-06-01 17:03:53 +02:00

242 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
// Flags live alongside the rest of the SVG flag library under
// assets/flags/1x1 and are filename-matched to the language code
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
// build time; the JS bundle only holds URL refs, not the SVG bytes.
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
eager: true,
import: "default",
query: "?url",
});
const flagByCode: Record<string, string> = {};
for (const path in FLAG_URLS) {
const match = path.match(/1x1\/([^/]+)\.svg$/);
if (match) flagByCode[match[1]] = FLAG_URLS[path];
}
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
function Flag({ code, label }: { code: string; label: string }) {
const src = flagFor(code);
if (!src) {
return (
<span
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
aria-hidden
/>
);
}
return (
<img
src={src}
alt={label}
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
draggable={false}
/>
);
}
export function LanguagePicker() {
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
loadLanguages()
.then((list) => {
if (!cancelled) setLanguages(list);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sorted = useMemo(
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
[languages],
);
const current = useMemo(
() =>
languages.find((l) => l.code === i18n.language) ??
languages.find((l) => l.code === "en"),
[languages, i18n.language],
);
const select = async (code: string) => {
if (busy || code === i18n.language) {
setOpen(false);
return;
}
setBusy(true);
try {
await Preferences.SetLanguage(code as LanguageCode);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
setOpen(false);
}
};
return (
<div className={"flex items-center gap-6 justify-between"}>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>{t("settings.general.language.label")}</Label>
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
disabled={busy || languages.length === 0}
className={cn(
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
"disabled:opacity-50",
)}
>
{current && <Flag code={current.code} label={current.displayName} />}
<span className={"truncate flex-1 text-left"}>
{current?.displayName ?? "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"start"}
sideOffset={6}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn(
"w-[var(--radix-popover-trigger-width)]",
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
"origin-[var(--radix-popover-content-transform-origin)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=top]:slide-in-from-bottom-1",
"duration-150 ease-out",
)}
>
<Command
loop
className={cn(
"flex flex-col",
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
)}
>
<div className={"px-1 pb-1"}>
<div className={"group flex items-center gap-2 px-1 h-8"}>
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
<Command.Input
autoFocus
placeholder={t("settings.general.language.search")}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"outline-none border-none",
)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
<div
className={
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
}
>
{t("settings.general.language.empty")}
</div>
</Command.Empty>
{sorted.map((lang) => {
const checked = lang.code === i18n.language;
return (
<Command.Item
key={lang.code}
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
onSelect={() => void select(lang.code)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none my-0.5",
"text-xs font-semibold text-nb-gray-200",
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
)}
>
<Flag
code={lang.code}
label={lang.displayName}
/>
<span className={"flex-1 truncate"}>
{lang.displayName}
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</Command.Item>
);
})}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
</div>
);
}