Add an always-visible language switcher to the top nav
A visitor who lands in a language they can't read had no discoverable way to change it: on desktop the only chrome was the search icon + login, and the picker was buried at /settings/language (itself only reachable through the mobile hamburger's "Settings" — a word they may not be able to read). Add a globe/languages icon to the top-nav right cluster, visible to everyone regardless of login state. It opens a dropdown listing every supported language by its own native name (each row rendered in its own lang/dir), so the right entry is recognizable no matter the current UI language. Selecting one switches in place via changeAppLanguage without navigating away, so scroll position and in-progress form state survive. The /settings/language page remains the canonical deep-link. New key nav.language added to en.json and all 15 other locales (reusing each locale's existing language.title translation).
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { Languages } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { SUPPORTED_LANGUAGES, changeAppLanguage, isRTLLanguage } from '@/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Resolve the live i18next language code to one of `SUPPORTED_LANGUAGES`'
|
||||
* codes so the right row shows as selected.
|
||||
*
|
||||
* Mirrors the logic in `LanguageSettingsPage` so the top-nav switcher and the
|
||||
* settings page agree on which language is active:
|
||||
* 1. Exact match (case-insensitive) — `zh-Hant` → `zh-Hant`, `en` → `en`.
|
||||
* 2. Traditional-Chinese aliases — `zh-TW` / `zh-HK` resolve to `zh-Hant`
|
||||
* because `i18n.ts` registers them as resource aliases.
|
||||
* 3. Base-code fallback — `en-US` → `en`, `pt-BR` → `pt`.
|
||||
*/
|
||||
function resolveCurrentLng(rawLng: string): string {
|
||||
const lower = rawLng.toLowerCase();
|
||||
const exact = SUPPORTED_LANGUAGES.find((l) => l.code.toLowerCase() === lower);
|
||||
if (exact) return exact.code;
|
||||
if (lower === 'zh-tw' || lower === 'zh-hk') return 'zh-Hant';
|
||||
return lower.split('-')[0];
|
||||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
/** Extra classes for the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact language picker for the top nav. A globe/languages icon opens a
|
||||
* dropdown of every supported language by its own native name, so a visitor
|
||||
* who can't read the current UI language can still recognize and pick theirs.
|
||||
*
|
||||
* Switching happens in place — `changeAppLanguage` swaps the active locale and
|
||||
* the app re-renders translated — so the user never loses their scroll
|
||||
* position, the campaign they're reading, or any in-progress form state. The
|
||||
* full `/settings/language` page remains the canonical deep-link.
|
||||
*
|
||||
* Each row renders in its own language and direction (`lang` + `dir`) so the
|
||||
* native names read correctly regardless of the current UI direction.
|
||||
*/
|
||||
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||
const { t, i18n: i18nInstance } = useTranslation();
|
||||
const currentLng = resolveCurrentLng(i18nInstance.language ?? 'en');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
className,
|
||||
)}
|
||||
aria-label={t('nav.language')}
|
||||
title={t('nav.language')}
|
||||
>
|
||||
<Languages className="size-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[70vh] w-56 overflow-y-auto">
|
||||
<DropdownMenuLabel>{t('language.title')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentLng}
|
||||
onValueChange={(code) => {
|
||||
if (code !== currentLng) void changeAppLanguage(code);
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((language) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={language.code}
|
||||
value={language.code}
|
||||
lang={language.code}
|
||||
dir={isRTLLanguage(language.code) ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{language.nativeName}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -19,6 +19,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -120,6 +121,11 @@ export function TopNav() {
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Always-visible language switcher so a visitor who can't read the
|
||||
current UI language can find and pick theirs — independent of
|
||||
login state. Switches in place without leaving the page. */}
|
||||
<LanguageSwitcher />
|
||||
|
||||
{user ? (
|
||||
<DeferredWalletBalancePill />
|
||||
) : (
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"groups": "المجموعات",
|
||||
"pledge": "التعهدات",
|
||||
"search": "بحث",
|
||||
"language": "اللغة",
|
||||
"dashboard": "لوحة التحكم",
|
||||
"myDashboard": "لوحتي",
|
||||
"wallet": "المحفظة",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"groups": "Groups",
|
||||
"pledge": "Pledge",
|
||||
"search": "Search",
|
||||
"language": "Language",
|
||||
"dashboard": "Dashboard",
|
||||
"myDashboard": "My Dashboard",
|
||||
"wallet": "Wallet",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Grupos",
|
||||
"pledge": "Promesas",
|
||||
"search": "Buscar",
|
||||
"language": "Idioma",
|
||||
"dashboard": "Panel",
|
||||
"myDashboard": "Mi panel",
|
||||
"wallet": "Cartera",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "گروهها",
|
||||
"pledge": "تعهدها",
|
||||
"search": "جستجو",
|
||||
"language": "زبان",
|
||||
"dashboard": "داشبورد",
|
||||
"myDashboard": "داشبورد من",
|
||||
"wallet": "کیف پول",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"groups": "Groupes",
|
||||
"pledge": "Promesse",
|
||||
"search": "Rechercher",
|
||||
"language": "Langue",
|
||||
"dashboard": "Tableau de bord",
|
||||
"myDashboard": "Mon tableau de bord",
|
||||
"wallet": "Portefeuille",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "ग्रुप",
|
||||
"pledge": "प्लेज",
|
||||
"search": "खोजें",
|
||||
"language": "भाषा",
|
||||
"dashboard": "डैशबोर्ड",
|
||||
"myDashboard": "मेरा डैशबोर्ड",
|
||||
"wallet": "वॉलेट",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Grup",
|
||||
"pledge": "Ikrar",
|
||||
"search": "Cari",
|
||||
"language": "Bahasa",
|
||||
"dashboard": "Dasbor",
|
||||
"myDashboard": "Dasbor Saya",
|
||||
"wallet": "Dompet",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "ក្រុម",
|
||||
"pledge": "ការសន្យា",
|
||||
"search": "ស្វែងរក",
|
||||
"language": "ភាសា",
|
||||
"dashboard": "ផ្ទាំងគ្រប់គ្រង",
|
||||
"myDashboard": "ផ្ទាំងគ្រប់គ្រងរបស់ខ្ញុំ",
|
||||
"wallet": "កាបូប",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "ډلې",
|
||||
"pledge": "ژمنې",
|
||||
"search": "لټون",
|
||||
"language": "ژبه",
|
||||
"dashboard": "ډیشبورډ",
|
||||
"myDashboard": "زما ډیشبورډ",
|
||||
"wallet": "بټوه",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Grupos",
|
||||
"pledge": "Promessa",
|
||||
"search": "Pesquisar",
|
||||
"language": "Idioma",
|
||||
"dashboard": "Painel",
|
||||
"myDashboard": "Meu painel",
|
||||
"wallet": "Carteira",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Группы",
|
||||
"pledge": "Обещание",
|
||||
"search": "Поиск",
|
||||
"language": "Язык",
|
||||
"dashboard": "Панель",
|
||||
"myDashboard": "Моя панель",
|
||||
"wallet": "Кошелёк",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Mapoka",
|
||||
"pledge": "Zvivimbiso",
|
||||
"search": "Tsvaga",
|
||||
"language": "Mutauro",
|
||||
"dashboard": "Dashboard",
|
||||
"myDashboard": "Dashboard Yangu",
|
||||
"wallet": "Chikwama",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "Vikundi",
|
||||
"pledge": "Ahadi",
|
||||
"search": "Tafuta",
|
||||
"language": "Lugha",
|
||||
"dashboard": "Dashibodi",
|
||||
"wallet": "Pochi",
|
||||
"notifications": "Arifa",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"groups": "Gruplar",
|
||||
"pledge": "Taahhüt",
|
||||
"search": "Ara",
|
||||
"language": "Dil",
|
||||
"dashboard": "Panel",
|
||||
"myDashboard": "Panelim",
|
||||
"wallet": "Cüzdan",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "群組",
|
||||
"pledge": "承諾",
|
||||
"search": "搜尋",
|
||||
"language": "語言",
|
||||
"dashboard": "儀表板",
|
||||
"myDashboard": "我的儀表板",
|
||||
"wallet": "錢包",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"groups": "群组",
|
||||
"pledge": "承诺",
|
||||
"search": "搜索",
|
||||
"language": "语言",
|
||||
"dashboard": "仪表板",
|
||||
"myDashboard": "我的仪表板",
|
||||
"wallet": "钱包",
|
||||
|
||||
Reference in New Issue
Block a user