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:
lemon
2026-06-14 16:43:58 -07:00
parent b034716fca
commit f6c5b69b5c
18 changed files with 119 additions and 0 deletions
+97
View File
@@ -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;
+6
View File
@@ -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 />
) : (
+1
View File
@@ -63,6 +63,7 @@
"groups": "المجموعات",
"pledge": "التعهدات",
"search": "بحث",
"language": "اللغة",
"dashboard": "لوحة التحكم",
"myDashboard": "لوحتي",
"wallet": "المحفظة",
+1
View File
@@ -68,6 +68,7 @@
"groups": "Groups",
"pledge": "Pledge",
"search": "Search",
"language": "Language",
"dashboard": "Dashboard",
"myDashboard": "My Dashboard",
"wallet": "Wallet",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Grupos",
"pledge": "Promesas",
"search": "Buscar",
"language": "Idioma",
"dashboard": "Panel",
"myDashboard": "Mi panel",
"wallet": "Cartera",
+1
View File
@@ -67,6 +67,7 @@
"groups": "گروه‌ها",
"pledge": "تعهدها",
"search": "جستجو",
"language": "زبان",
"dashboard": "داشبورد",
"myDashboard": "داشبورد من",
"wallet": "کیف پول",
+1
View File
@@ -66,6 +66,7 @@
"groups": "Groupes",
"pledge": "Promesse",
"search": "Rechercher",
"language": "Langue",
"dashboard": "Tableau de bord",
"myDashboard": "Mon tableau de bord",
"wallet": "Portefeuille",
+1
View File
@@ -67,6 +67,7 @@
"groups": "ग्रुप",
"pledge": "प्लेज",
"search": "खोजें",
"language": "भाषा",
"dashboard": "डैशबोर्ड",
"myDashboard": "मेरा डैशबोर्ड",
"wallet": "वॉलेट",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Grup",
"pledge": "Ikrar",
"search": "Cari",
"language": "Bahasa",
"dashboard": "Dasbor",
"myDashboard": "Dasbor Saya",
"wallet": "Dompet",
+1
View File
@@ -67,6 +67,7 @@
"groups": "ក្រុម",
"pledge": "ការសន្យា",
"search": "ស្វែងរក",
"language": "ភាសា",
"dashboard": "ផ្ទាំងគ្រប់គ្រង",
"myDashboard": "ផ្ទាំងគ្រប់គ្រងរបស់ខ្ញុំ",
"wallet": "កាបូប",
+1
View File
@@ -67,6 +67,7 @@
"groups": "ډلې",
"pledge": "ژمنې",
"search": "لټون",
"language": "ژبه",
"dashboard": "ډیشبورډ",
"myDashboard": "زما ډیشبورډ",
"wallet": "بټوه",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Grupos",
"pledge": "Promessa",
"search": "Pesquisar",
"language": "Idioma",
"dashboard": "Painel",
"myDashboard": "Meu painel",
"wallet": "Carteira",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Группы",
"pledge": "Обещание",
"search": "Поиск",
"language": "Язык",
"dashboard": "Панель",
"myDashboard": "Моя панель",
"wallet": "Кошелёк",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Mapoka",
"pledge": "Zvivimbiso",
"search": "Tsvaga",
"language": "Mutauro",
"dashboard": "Dashboard",
"myDashboard": "Dashboard Yangu",
"wallet": "Chikwama",
+1
View File
@@ -67,6 +67,7 @@
"groups": "Vikundi",
"pledge": "Ahadi",
"search": "Tafuta",
"language": "Lugha",
"dashboard": "Dashibodi",
"wallet": "Pochi",
"notifications": "Arifa",
+1
View File
@@ -66,6 +66,7 @@
"groups": "Gruplar",
"pledge": "Taahhüt",
"search": "Ara",
"language": "Dil",
"dashboard": "Panel",
"myDashboard": "Panelim",
"wallet": "Cüzdan",
+1
View File
@@ -67,6 +67,7 @@
"groups": "群組",
"pledge": "承諾",
"search": "搜尋",
"language": "語言",
"dashboard": "儀表板",
"myDashboard": "我的儀表板",
"wallet": "錢包",
+1
View File
@@ -67,6 +67,7 @@
"groups": "群组",
"pledge": "承诺",
"search": "搜索",
"language": "语言",
"dashboard": "仪表板",
"myDashboard": "我的仪表板",
"wallet": "钱包",