feat(i18n): add searchable locale picker
This commit is contained in:
parent
777d227eb9
commit
418398587c
49 changed files with 318 additions and 31 deletions
111
web/src/components/LocalePicker.tsx
Normal file
111
web/src/components/LocalePicker.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { CheckIcon, ChevronDownIcon, GlobeIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { locales } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getLocaleDisplayName, localeMatchesSearch, useTranslate } from "@/utils/i18n";
|
||||
|
||||
const MISSING_LANGUAGE_FEEDBACK_URL =
|
||||
"https://github.com/usememos/memos/issues/new?title=Missing%20language%20support&body=Please%20add%20support%20for%20this%20language%3A%0A%0A-%20Language%3A%20";
|
||||
|
||||
interface LocaleSearchListProps {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LocaleSearchList = (props: LocaleSearchListProps) => {
|
||||
const { value, onChange, className } = props;
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const filteredLocales = useMemo(
|
||||
() => locales.filter((locale) => localeMatchesSearch(locale, query, i18n.language)),
|
||||
[i18n.language, query],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("w-64 max-w-[calc(100vw-2rem)]", className)}>
|
||||
<div className="relative p-1">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
className="pl-8"
|
||||
placeholder={`${t("common.search")} ${t("common.language").toLowerCase()}`}
|
||||
aria-label={t("common.language")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 max-h-72 overflow-y-auto p-1" role="listbox" aria-label={t("common.language")}>
|
||||
{filteredLocales.map((locale) => (
|
||||
<button
|
||||
type="button"
|
||||
key={locale}
|
||||
role="option"
|
||||
aria-selected={value === locale}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 rounded-sm px-2 text-left text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
value === locale && "bg-accent/60",
|
||||
)}
|
||||
onClick={() => onChange(locale)}
|
||||
>
|
||||
{value === locale ? <CheckIcon className="size-4 text-primary" /> : <span className="size-4" />}
|
||||
<span className="min-w-0 flex-1 truncate">{getLocaleDisplayName(locale)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{locale}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredLocales.length === 0 && (
|
||||
<div className="px-2 py-6 text-center text-sm">
|
||||
<a
|
||||
href={MISSING_LANGUAGE_FEEDBACK_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("locale-picker.no-language-feedback")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface LocalePickerProps {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocalePicker = (props: LocalePickerProps) => {
|
||||
const { value, onChange, className } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleChange = (locale: Locale) => {
|
||||
onChange(locale);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className={cn("w-full justify-between", className)}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="truncate">{getLocaleDisplayName(value)}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<LocaleSearchList value={value} onChange={handleChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalePicker;
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { GlobeIcon } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { locales } from "@/i18n";
|
||||
import { getLocaleDisplayName, loadLocale } from "@/utils/i18n";
|
||||
import LocalePicker from "@/components/LocalePicker";
|
||||
import { loadLocale } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
|
|
@ -19,23 +17,7 @@ const LocaleSelect: FC<Props> = (props: Props) => {
|
|||
onChange(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="w-4 h-auto" />
|
||||
<SelectValue placeholder="Select language" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{getLocaleDisplayName(locale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return <LocalePicker value={value} onChange={handleSelectChange} />;
|
||||
};
|
||||
|
||||
export default LocaleSelect;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
|
||||
import { locales } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { getLocaleDisplayName, getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n";
|
||||
import { getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n";
|
||||
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
|
||||
import { LocaleSearchList } from "./LocalePicker";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -151,14 +151,8 @@ const UserMenu = (props: Props) => {
|
|||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto">
|
||||
{locales.map((locale) => (
|
||||
<DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}>
|
||||
{currentLocale === locale && <CheckIcon className="w-4 h-auto" />}
|
||||
{currentLocale !== locale && <span className="w-4" />}
|
||||
{getLocaleDisplayName(locale)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSubContent className="p-0">
|
||||
<LocaleSearchList value={currentLocale} onChange={handleLocaleChange} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "الرؤية",
|
||||
"yourself": "نفسك"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "لم يتم العثور على اللغة؟ إرسال ردود الفعل"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "أضف تعليقك هنا...",
|
||||
"any-thoughts": "أية أفكار...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Видимост",
|
||||
"yourself": "Себе си"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Езикът не е намерен? Изпратете обратна връзка"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Добавете своя коментар тук...",
|
||||
"any-thoughts": "Някакви мисли...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Buit",
|
||||
"unlink": "Desenllaça"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "No s'ha trobat l'idioma? Envieu comentaris"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Afegeix el teu comentari aquí...",
|
||||
"any-thoughts": "Alguna idea...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Prázdný",
|
||||
"unlink": "Odpojit"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Jazyk nenalezen? Odeslat zpětnou vazbu"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Přidejte sem svůj komentář...",
|
||||
"any-thoughts": "Jakékoli myšlenky...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Sigtbarhed",
|
||||
"yourself": "Dig selv"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Sprog ikke fundet? Send feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Tilføj din kommentar her...",
|
||||
"any-thoughts": "Nogen tanker...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Leer",
|
||||
"unlink": "Verknüpfung aufheben"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Sprache nicht gefunden? Geben Sie Feedback ab"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Füge deinen Kommentar hinzu...",
|
||||
"any-thoughts": "Ein Gedanke...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Ορατότητα",
|
||||
"yourself": "Σύ ο ίδιος"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Δεν βρέθηκε η γλώσσα; Υποβολή σχολίων"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Προσθέστε το σχόλιό σας εδώ...",
|
||||
"any-thoughts": "Οποιαδήποτε σκέψη...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Empty",
|
||||
"unlink": "Unlink"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Language not found? Submit feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Add your comment here...",
|
||||
"any-thoughts": "Any thoughts...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Visibility",
|
||||
"yourself": "Yourself"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Language not found? Submit feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Add your comment here...",
|
||||
"any-thoughts": "Any thoughts...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Vacía",
|
||||
"unlink": "Desconectar"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "¿Idioma no encontrado? Enviar comentarios"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Agrega tu comentario aquí...",
|
||||
"any-thoughts": "Alguna idea...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Nähtavus",
|
||||
"yourself": "iseennast"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Keelt ei leitud? Esitage tagasisidet"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Lisa oma kommentaar siia...",
|
||||
"any-thoughts": "Mingid mõtted...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "قابل مشاهده توسط",
|
||||
"yourself": "خودتان"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "زبان پیدا نشد؟ ارسال بازخورد"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "نظر خود را اینجا اضافه کنید...",
|
||||
"any-thoughts": "فکر شما...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Näkyvyys",
|
||||
"yourself": "itseäsi"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Eikö kieltä löydy? Lähetä palautetta"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Lisää kommenttisi tähän...",
|
||||
"any-thoughts": "Mitään ajatuksia...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Vide",
|
||||
"unlink": "Dissocier"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Langue introuvable ? Soumettre des commentaires"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Ajoutez votre commentaire ici...",
|
||||
"any-thoughts": "Des idées...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Baleiro",
|
||||
"unlink": "Desvincular"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Non se atopou o idioma? Enviar comentarios"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Engade aquí o comentario...",
|
||||
"any-thoughts": "O que pensas...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "दृश्यता",
|
||||
"yourself": "खुद"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "भाषा नहीं मिली? प्रतिक्रिया सबमिट करें"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "अपनी टिप्पणी यहाँ जोड़ें...",
|
||||
"any-thoughts": "कोई विचार...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Prazan",
|
||||
"unlink": "Prekini vezu"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Jezik nije pronađen? Pošaljite povratne informacije"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Dodaj svoj komentar ovdje...",
|
||||
"any-thoughts": "Imaš li misli...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Üres",
|
||||
"unlink": "Leválasztás"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Nem található a nyelv? Visszajelzés küldése"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Írd ide a megjegyzésed...",
|
||||
"any-thoughts": "Bármi ami a fejedben jár...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "Visibilitas",
|
||||
"yourself": "Anda sendiri"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Bahasa tidak ditemukan? Kirim masukan"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Tambahkan komentar Anda di sini...",
|
||||
"any-thoughts": "Punya pemikiran...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Vuota",
|
||||
"unlink": "Scollega"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Lingua non trovata? Invia feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Aggiungi qui il tuo commento...",
|
||||
"any-thoughts": "Qualsiasi cosa pensi...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "空の",
|
||||
"unlink": "リンクを解除する"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "言語が見つかりませんか? フィードバックを送信する"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "ここにコメントを追加...",
|
||||
"any-thoughts": "今思ったことは...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "ცარიელი",
|
||||
"unlink": "გაუქმება"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "ენა ვერ მოიძებნა? გამოხმაურება"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "დაამატეთ თქვენი კომენტარი აქ...",
|
||||
"any-thoughts": "რამე იდეები...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "공개 범위",
|
||||
"yourself": "자기 자신"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "언어를 찾을 수 없나요? 피드백 제출"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "여기에 댓글을 추가하세요...",
|
||||
"any-thoughts": "떠오르는 게 있나요...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Matomumas",
|
||||
"yourself": "save"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Kalba nerasta? Pateikite atsiliepimą"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Pridėkite savo komentarą čia...",
|
||||
"any-thoughts": "Bet kokios mintys...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Redzamība",
|
||||
"yourself": "sevi"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Valoda nav atrasta? Iesniedziet atsauksmes"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Pievienojiet savu komentāru šeit...",
|
||||
"any-thoughts": "Jebkuras domas...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "दृश्यमानता",
|
||||
"yourself": "स्वतः"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "भाषा सापडली नाही? अभिप्राय सबमिट करा"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "तुमची टिप्पणी येथे जो़डा",
|
||||
"any-thoughts": "आपले विचार...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Tømme",
|
||||
"unlink": "Fjern tilknytningen"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Finner du ikke språket? Send tilbakemelding"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Legg til din kommentar her...",
|
||||
"any-thoughts": "Noen tanker...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Leeg",
|
||||
"unlink": "Ontkoppelen"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Taal niet gevonden? Geef feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Voeg hier je opmerking toe…",
|
||||
"any-thoughts": "Enkele gedachten…",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Pusty",
|
||||
"unlink": "Odczepić"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Nie znaleziono języka? Prześlij opinię"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Dodaj swój komentarz tutaj...",
|
||||
"any-thoughts": "Jakieś przemyślenia...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "Visibilidade",
|
||||
"yourself": "Você mesmo"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Idioma não encontrado? Enviar comentários"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Adicione seu comentário aqui…",
|
||||
"any-thoughts": "Alguma ideia…",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Vazia",
|
||||
"unlink": "Desvincular"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Idioma não encontrado? Enviar comentários"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Adicione o seu comentário aqui...",
|
||||
"any-thoughts": "Alguma ideia…",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Vizibilitate",
|
||||
"yourself": "Te"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Limba nu a fost găsită? Trimiteți feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Adauga comentariul tau aici...",
|
||||
"any-thoughts": "Orice gânduri...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Пустой",
|
||||
"unlink": "Отсоединить"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Язык не найден? Отправить отзыв"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Напишите свой комментарий...",
|
||||
"any-thoughts": "Напишите что-нибудь...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Viditeľnosť",
|
||||
"yourself": "seba"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Jazyk sa nenašiel? Odoslať spätnú väzbu"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Tu pridajte svoj komentár...",
|
||||
"any-thoughts": "Akékoľvek myšlienky...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "prazno",
|
||||
"unlink": "Prekini povezavo"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Jezik ni bil najden? Pošlji povratne informacije"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Tu dodaj svoj komentar...",
|
||||
"any-thoughts": "Kakšne misli...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "Видљивост",
|
||||
"yourself": "Себе"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Језик није пронађен? Пошаљите повратне информације"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Додајте свој коментар овде...",
|
||||
"any-thoughts": "Било каква размишљања...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Tömma",
|
||||
"unlink": "Ta bort länken"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Språket hittades inte? Skicka feedback"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Lägg till din kommentar här...",
|
||||
"any-thoughts": "Några tankar...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "การมองเห็น",
|
||||
"yourself": "ตัวคุณ"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "ไม่พบภาษา? ส่งข้อเสนอแนะ"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "เพิ่มความคิดเห็นของคุณที่นี่...",
|
||||
"any-thoughts": "ความคิดใดๆ...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Boş",
|
||||
"unlink": "Bağlantıyı kaldır"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Dil bulunamadı mı? Geri bildirim gönder"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Yorumunuzu buraya ekleyin...",
|
||||
"any-thoughts": "Düşünceleriniz...",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@
|
|||
"empty-placeholder": "Порожній",
|
||||
"unlink": "Від’єднати"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Мова не знайдена? Надіслати відгук"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Додайте свій коментар тут...",
|
||||
"any-thoughts": "Якісь думки...",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@
|
|||
"visibility": "Quyền xem",
|
||||
"yourself": "Bản thân"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "Không tìm thấy ngôn ngữ? Gửi phản hồi"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "Thêm bình luận của bạn tại đây...",
|
||||
"any-thoughts": "Có suy nghĩ gì...",
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@
|
|||
"visibility": "可见性",
|
||||
"yourself": "您自己"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "未找到语言? 提交反馈"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "请输入您的评论...",
|
||||
"any-thoughts": "此刻的想法...",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
"visibility": "瀏覽權限",
|
||||
"yourself": "您自己"
|
||||
},
|
||||
"locale-picker": {
|
||||
"no-language-feedback": "未找到語言? 提交回饋"
|
||||
},
|
||||
"editor": {
|
||||
"add-your-comment-here": "在這裡添加您的評論...",
|
||||
"any-thoughts": "任何想法...",
|
||||
|
|
|
|||
|
|
@ -122,3 +122,36 @@ export const getLocaleDisplayName = (locale: string): string => {
|
|||
}
|
||||
return locale;
|
||||
};
|
||||
|
||||
export const normalizeLocaleSearchText = (value: string): string => {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const getLocaleDisplayNameForLanguage = (locale: string, displayLanguage: string): string => {
|
||||
try {
|
||||
return new Intl.DisplayNames([displayLanguage], { type: "language" }).of(locale) ?? locale;
|
||||
} catch {
|
||||
return locale;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLocaleSearchLabels = (locale: string, uiLocale: string): string[] => {
|
||||
return Array.from(
|
||||
new Set([
|
||||
locale,
|
||||
getLocaleDisplayNameForLanguage(locale, locale),
|
||||
getLocaleDisplayNameForLanguage(locale, "en"),
|
||||
getLocaleDisplayNameForLanguage(locale, uiLocale),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const localeMatchesSearch = (locale: string, query: string, uiLocale: string): boolean => {
|
||||
const normalizedQuery = normalizeLocaleSearchText(query.trim());
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
return getLocaleSearchLabels(locale, uiLocale).some((label) => normalizeLocaleSearchText(label).includes(normalizedQuery));
|
||||
};
|
||||
|
|
|
|||
24
web/tests/i18n-locale-search.test.ts
Normal file
24
web/tests/i18n-locale-search.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getLocaleSearchLabels, localeMatchesSearch, normalizeLocaleSearchText } from "@/utils/i18n";
|
||||
|
||||
describe("locale search helpers", () => {
|
||||
it("normalizes case and diacritics for locale search", () => {
|
||||
expect(normalizeLocaleSearchText("Português")).toBe("portugues");
|
||||
});
|
||||
|
||||
it("includes locale code, native name, and English name", () => {
|
||||
const labels = getLocaleSearchLabels("ja", "en");
|
||||
|
||||
expect(labels).toContain("ja");
|
||||
expect(labels).toContain("日本語");
|
||||
expect(labels).toContain("Japanese");
|
||||
});
|
||||
|
||||
it("matches by code, native display name, English display name, and accent-free text", () => {
|
||||
expect(localeMatchesSearch("pt-PT", "pt", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("ja", "日本", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("de", "german", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("pt-PT", "portugues", "en")).toBe(true);
|
||||
expect(localeMatchesSearch("ja", "romanian", "en")).toBe(false);
|
||||
});
|
||||
});
|
||||
14
web/tests/locale-picker.test.tsx
Normal file
14
web/tests/locale-picker.test.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LocaleSearchList } from "@/components/LocalePicker";
|
||||
|
||||
describe("<LocaleSearchList>", () => {
|
||||
it("links to GitHub feedback when no locale matches the search", async () => {
|
||||
render(<LocaleSearchList value="en" onChange={() => {}} />);
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Language" }), { target: { value: "klingon" } });
|
||||
|
||||
const feedbackLink = screen.getByRole("link", { name: "Language not found? Submit feedback" });
|
||||
expect(feedbackLink).toHaveAttribute("href", expect.stringContaining("https://github.com/usememos/memos/issues/new"));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue