mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-24 01:11:29 +02:00
206 lines
11 KiB
TypeScript
206 lines
11 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 { CheckIcon, ChevronDown, LanguagesIcon, 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 { errorDialog, formatErrorMessage } from "@/lib/errors";
|
|
|
|
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
|
|
|
|
const labelFor = (lang: Language): string =>
|
|
lang.englishName && lang.englishName !== lang.displayName
|
|
? `${lang.displayName} (${lang.englishName})`
|
|
: lang.displayName;
|
|
|
|
export function LanguagePicker() {
|
|
const { t, i18n } = useTranslation();
|
|
const [languages, setLanguages] = useState<Language[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
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",
|
|
)}
|
|
>
|
|
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
|
|
<span className={"truncate flex-1 text-left"}>
|
|
{current ? labelFor(current) : "—"}
|
|
</span>
|
|
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
|
|
</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",
|
|
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
|
|
"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",
|
|
)}
|
|
>
|
|
<span className={"flex-1 min-w-0 truncate"}>
|
|
{labelFor(lang)}
|
|
</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>
|
|
);
|
|
}
|