fix(menus): move language into its own mobile view; fit crew tabs

This commit is contained in:
SkateSpots 2026-10-27 17:00:19 +07:00
parent 5ac9e67860
commit 74249e304e
5 changed files with 154 additions and 83 deletions

View file

@ -7,47 +7,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTranslation, type Language } from '@/contexts/language';
import { useTranslation } from '@/contexts/language';
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
import { cn } from '@/lib/utils';
interface LanguageOption {
code: Language;
name: string;
nativeName: string;
flag: string;
}
const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '/flags/gb-eng.svg' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '/flags/ca.svg' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '/flags/eu.svg' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '/flags/gl.svg' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', flag: '🇧🇷' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)', flag: '🇵🇹' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱' },
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵' },
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', flag: '🇹🇼' },
{ code: 'uk', name: 'Ukrainian', nativeName: 'Українська', flag: '🇺🇦' },
{ code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', flag: '🇮🇩' },
{ code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿' },
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴' },
{ code: 'sv', name: 'Swedish', nativeName: 'Svenska', flag: '🇸🇪' },
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flag: '🇬🇷' },
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flag: '🇻🇳' },
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷' },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳' },
];
interface LanguageToggleProps {
/** Show only the flag icon (compact mode) */
compact?: boolean;
@ -93,17 +56,23 @@ export function LanguageToggle({ compact = false, inline = false, className, var
const { language, setLanguage, t } = useTranslation();
const [query, setQuery] = useState('');
// Auto-focusing the search on a phone pops the keyboard over the list before
// the user can see the languages. Keep it for mouse/pen, not touch.
const autoFocusSearch =
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: fine)').matches;
const safeQuery = query.trim().toLowerCase();
const filteredLanguages = safeQuery
? languages.filter(
? LANGUAGE_OPTIONS.filter(
(l) =>
l.name.toLowerCase().includes(safeQuery) ||
l.nativeName.toLowerCase().includes(safeQuery) ||
l.code.toLowerCase().includes(safeQuery)
)
: languages;
: LANGUAGE_OPTIONS;
const currentLanguage = languages.find(l => l.code === language) || languages[0];
const currentLanguage =
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
if (inline) {
return (
@ -177,7 +146,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
onPointerDown={(e) => e.stopPropagation()}
>
<input
autoFocus
autoFocus={autoFocusSearch}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('nav.searchLanguages')}
@ -189,7 +158,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
key={lang.code}
onClick={() => setLanguage(lang.code)}
className={cn(
'flex items-center gap-3 cursor-pointer',
'flex min-h-11 items-center gap-3 cursor-pointer',
language === lang.code && 'bg-primary/10 text-primary'
)}
>
@ -225,7 +194,7 @@ export function LanguageSelector() {
<div className="space-y-3">
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{languages.map((lang) => (
{LANGUAGE_OPTIONS.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code)}

View file

@ -16,6 +16,7 @@ import {
import { LoginArea } from '@/components/auth/LoginArea';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { LanguageToggle } from '@/components/LanguageToggle';
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
import { clearTranslationMemory } from '@/lib/translation';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
@ -46,6 +47,9 @@ import {
SlidersHorizontal,
BookOpen,
PersonStanding,
Languages,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -150,14 +154,14 @@ function NavLink({
to={href}
onClick={onClick}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200',
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-200',
isActive
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
)}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{label}</span>
<Icon className="h-5 w-5 shrink-0" />
<span className="min-w-0 flex-1 break-words font-medium">{label}</span>
</Link>
);
}
@ -203,7 +207,7 @@ function MobileHeaderUserMenu({
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-2">
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
{/* User info header */}
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-3">
@ -513,6 +517,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
// padding even though it shares this one persistent layout with every route.
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileLanguageView, setMobileLanguageView] = useState(false);
const [showStatusDialog, setShowStatusDialog] = useState(false);
const [showCustomizeNav, setShowCustomizeNav] = useState(false);
const [showAddAccount, setShowAddAccount] = useState(false);
@ -539,7 +544,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
const timer = window.setTimeout(() => setShowWelcomeLogin(true), 900);
return () => window.clearTimeout(timer);
}, [user]);
const { t } = useTranslation();
const { t, language } = useTranslation();
const currentLanguage = LANGUAGE_OPTIONS.find((item) => item.code === language) ?? LANGUAGE_OPTIONS[0];
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
@ -624,7 +630,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{t('nav.more')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuContent align="end" className="w-64 [&_[role=menuitem]]:min-h-10">
{/* Catalog items the user hasn't pinned to the top bar.
Auth-only items (stash/collections/contacts/wallet/messages)
only show when logged in. */}
@ -822,7 +828,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
<LoginArea compact className="flex flex-row gap-1" />
</div>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<Sheet
open={mobileMenuOpen}
onOpenChange={(open) => {
setMobileMenuOpen(open);
if (!open) setMobileLanguageView(false);
}}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('chromeUi.menu')} className="h-10 w-10 touch-manipulation active:scale-95 transition-transform">
<Menu className="h-5 w-5" />
@ -830,7 +842,43 @@ export function Layout({ children, noPadding }: LayoutProps) {
</SheetTrigger>
<SheetContent side="right" className="w-80 overflow-y-auto">
<SheetTitle className="sr-only">{t('chromeUi.menu')}</SheetTitle>
{mobileLanguageView ? (
<div className="flex flex-col gap-4 mt-6 pb-8">
<button
type="button"
onClick={() => setMobileLanguageView(false)}
className="flex items-center gap-2 self-start rounded-lg px-1 py-1 text-sm font-bold text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" /> {t('common.back')}
</button>
<div>
<h2 className="text-xl font-black uppercase tracking-wide">
{t('settings.selectLanguage')}
</h2>
<LanguageToggle inline className="mt-3 w-full" variant="ghost" />
</div>
</div>
) : (
<div className="flex flex-col gap-6 mt-6 pb-8">
{/* Language picker lives in its own view — 28 languages inline
made this sheet ~3,200px tall and buried the control. */}
<button
type="button"
onClick={() => setMobileLanguageView(true)}
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left transition-colors hover:bg-white/10 active:scale-[0.99]"
>
<Languages className="h-5 w-5 shrink-0 text-teal-400" />
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{t('settings.selectLanguage')}
</span>
<span className="block truncate text-sm font-medium">
{currentLanguage.nativeName}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{/* Wallet Card - Mobile Menu */}
{user && (
<Link
@ -1005,11 +1053,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
<span className="font-medium">{t('chromeUi.customizeNavBar')}</span>
</button>
</div>
{/* Language Toggle in Mobile Menu — inline (a portaled dropdown
is unclickable inside a Radix Sheet) */}
<div className="py-3 border-t">
<LanguageToggle inline className="w-full" variant="ghost" />
</div>
{/* Login/Logout Section */}
<div className="pt-4 border-t space-y-3">
{user ? (
@ -1063,6 +1106,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>

View file

@ -5,6 +5,7 @@ import {
Check,
ChevronDown,
Copy,
ExternalLink,
ImagePlus,
KeyRound,
Loader2,
@ -780,6 +781,7 @@ function AdvancedPanel({
</span>
</div>
{groupRef && (
<>
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<span className="shrink-0">{t('cordnUi.groupRef')}</span>
<code className="min-w-0 flex-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
@ -799,6 +801,16 @@ function AdvancedPanel({
{copied ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
<a
href={`https://cordn.net/chat/${groupRef}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-teal-300 transition-colors hover:text-teal-200"
>
<ExternalLink className="h-3 w-3" />{' '}
{t('socialUi.crewCoordinator.openCordnGroup')}
</a>
</>
)}
</div>
</details>

View file

@ -0,0 +1,43 @@
import type { Language } from '@/contexts/language';
export interface LanguageOption {
code: Language;
name: string;
nativeName: string;
flag: string;
}
/**
* Every locale the picker can show. Kept out of the component file so Fast
* Refresh still works (a component module must only export components).
*/
export const LANGUAGE_OPTIONS: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '/flags/gb-eng.svg' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '/flags/ca.svg' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '/flags/eu.svg' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '/flags/gl.svg' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', flag: '🇧🇷' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)', flag: '🇵🇹' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱' },
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵' },
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', flag: '🇹🇼' },
{ code: 'uk', name: 'Ukrainian', nativeName: 'Українська', flag: '🇺🇦' },
{ code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', flag: '🇮🇩' },
{ code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿' },
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴' },
{ code: 'sv', name: 'Swedish', nativeName: 'Svenska', flag: '🇸🇪' },
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flag: '🇬🇷' },
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flag: '🇻🇳' },
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷' },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳' },
];

View file

@ -347,18 +347,21 @@ export default function CrewDetailPage() {
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4" /> {t('socialUi.crew.eventsTab')}
<TabsList className="grid w-full grid-cols-[1.1fr_1fr_1.3fr] bg-black/60 border border-white/10">
<TabsTrigger value="events" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4 shrink-0" />{' '}
<span className="min-w-0 truncate">{t('socialUi.crew.eventsTab')}</span>
{crewEvents && crewEvents.length > 0 && (
<span className="ml-0.5 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
<span className="ml-0.5 shrink-0 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
)}
</TabsTrigger>
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4" /> {t('socialUi.crew.chatTab')}
<TabsTrigger value="chat" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<MessageSquare className="h-4 w-4 shrink-0" />{' '}
<span className="min-w-0 truncate">{t('socialUi.crew.chatTab')}</span>
</TabsTrigger>
<TabsTrigger value="mls" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<Lock className="h-4 w-4" /> {t('cordnUi.title')}
<TabsTrigger value="mls" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<Lock className="h-4 w-4 shrink-0" />{' '}
<span className="min-w-0 truncate">{t('cordnUi.title')}</span>
</TabsTrigger>
</TabsList>