277 lines
9.7 KiB
TypeScript
277 lines
9.7 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
import type { HeaderExportState, Page } from './Header';
|
|
import { PAGE_PATHS } from './Header';
|
|
import type { AuthUser } from '../../hooks/useAuth';
|
|
import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n';
|
|
import { DownloadIcon } from './icons/DownloadIcon';
|
|
import { BookmarkIcon } from './icons/BookmarkIcon';
|
|
import { CheckIcon } from './icons/CheckIcon';
|
|
import { ClipboardIcon } from './icons/ClipboardIcon';
|
|
import { CloseIcon } from './icons/CloseIcon';
|
|
import { SunIcon } from './icons/SunIcon';
|
|
import { MoonIcon } from './icons/MoonIcon';
|
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
|
|
|
interface MobileMenuProps {
|
|
activePage: Page;
|
|
activeHash: string;
|
|
onPageChange: (page: Page, hash?: string) => void;
|
|
theme: 'light' | 'dark';
|
|
onToggleTheme: () => void;
|
|
exportState: HeaderExportState | null;
|
|
onSaveSearch: (() => void) | null;
|
|
savingSearch: boolean;
|
|
isEditingSearch: boolean;
|
|
user: AuthUser | null;
|
|
onLoginClick: () => void;
|
|
onRegisterClick: () => void;
|
|
onLogout: () => void;
|
|
onClose: () => void;
|
|
onShare: () => void;
|
|
copied: boolean;
|
|
sharing: boolean;
|
|
}
|
|
|
|
export default function MobileMenu({
|
|
activePage,
|
|
activeHash,
|
|
onPageChange,
|
|
theme,
|
|
onToggleTheme,
|
|
exportState,
|
|
onSaveSearch,
|
|
savingSearch,
|
|
isEditingSearch,
|
|
user,
|
|
onLoginClick,
|
|
onRegisterClick,
|
|
onLogout,
|
|
onClose,
|
|
onShare,
|
|
copied,
|
|
sharing,
|
|
}: MobileMenuProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const emailParts = user?.email.split('@');
|
|
const emailLocal = emailParts?.[0] ?? '';
|
|
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
|
|
|
|
const mobileNavItem = (page: Page, label: string, hash?: string) => {
|
|
const isActive =
|
|
activePage === page &&
|
|
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites');
|
|
const href = hash ? `${PAGE_PATHS[page]}#${hash}` : PAGE_PATHS[page];
|
|
|
|
return (
|
|
<a
|
|
key={hash ? `${page}-${hash}` : page}
|
|
href={href}
|
|
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
|
|
isActive ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
|
}`}
|
|
onClick={(e) => {
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
onPageChange(page, hash);
|
|
onClose();
|
|
}}
|
|
>
|
|
{label}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
const dashboardActionClass =
|
|
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
|
|
|
|
const dashboardSavedItem = user && (
|
|
<a
|
|
href={PAGE_PATHS.saved}
|
|
className={dashboardActionClass}
|
|
onClick={(e) => {
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
onPageChange('saved');
|
|
onClose();
|
|
}}
|
|
>
|
|
{t('header.saved')}
|
|
</a>
|
|
);
|
|
|
|
const dashboardActions = activePage === 'dashboard' && (
|
|
<div className="px-2 py-2 border-b border-navy-700">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => {
|
|
onShare();
|
|
onClose();
|
|
}}
|
|
disabled={sharing}
|
|
className={dashboardActionClass}
|
|
>
|
|
{sharing ? (
|
|
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
|
) : copied ? (
|
|
<CheckIcon className="w-4 h-4" />
|
|
) : (
|
|
<ClipboardIcon className="w-4 h-4" />
|
|
)}
|
|
{sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')}
|
|
</button>
|
|
{exportState && (
|
|
<button
|
|
onClick={() => {
|
|
exportState.onExport();
|
|
onClose();
|
|
}}
|
|
disabled={exportState.exporting}
|
|
className={dashboardActionClass}
|
|
>
|
|
<DownloadIcon className="w-4 h-4" />
|
|
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
|
</button>
|
|
)}
|
|
{onSaveSearch && (
|
|
<button
|
|
onClick={() => {
|
|
onSaveSearch();
|
|
onClose();
|
|
}}
|
|
disabled={savingSearch}
|
|
className={dashboardActionClass}
|
|
>
|
|
{savingSearch ? (
|
|
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<BookmarkIcon className="w-4 h-4" />
|
|
)}
|
|
{isEditingSearch ? t('common.update') : t('common.save')}
|
|
</button>
|
|
)}
|
|
{dashboardSavedItem}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
|
|
{/* Menu panel */}
|
|
<div className="mobile-menu-panel fixed top-0 right-0 bottom-0 w-64 bg-navy-900 text-white z-[80] flex flex-col shadow-xl">
|
|
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
|
|
<span className="font-semibold">{t('mobileMenu.menu')}</span>
|
|
<button
|
|
onClick={onClose}
|
|
className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
|
|
aria-label={t('header.closeMenu')}
|
|
>
|
|
<CloseIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
{dashboardActions}
|
|
<nav className="flex-1 flex flex-col gap-0.5 p-2 overflow-y-auto">
|
|
{mobileNavItem('home', t('mobileMenu.home'))}
|
|
{mobileNavItem('dashboard', t('header.dashboard'))}
|
|
{mobileNavItem('learn', t('header.learn'))}
|
|
{user?.subscription !== 'licensed' &&
|
|
!user?.isAdmin &&
|
|
mobileNavItem('pricing', t('header.pricing'))}
|
|
{user && mobileNavItem('account', t('header.inviteFriends'), 'invites')}
|
|
{user && mobileNavItem('account', t('userMenu.account'))}
|
|
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
|
|
</nav>
|
|
|
|
{/* Theme toggle + Auth section at bottom */}
|
|
<div className="p-2 border-t border-navy-700 flex flex-col gap-2">
|
|
{/* Theme toggle */}
|
|
<button
|
|
onClick={() => {
|
|
onToggleTheme();
|
|
}}
|
|
className="w-full flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
|
|
>
|
|
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
|
|
<span>{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}</span>
|
|
</button>
|
|
|
|
{/* Language selector */}
|
|
<div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide">
|
|
{SUPPORTED_LANGUAGES.map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
aria-label={lang.label}
|
|
onClick={() => {
|
|
localStorage.setItem('language', lang.code);
|
|
void changeAppLanguage(lang.code);
|
|
}}
|
|
className={`flex-none min-w-[2.5rem] flex cursor-pointer items-center justify-center gap-1.5 px-2 py-1.5 rounded text-sm ${
|
|
i18n.language === lang.code
|
|
? 'bg-navy-700 text-white font-medium'
|
|
: 'text-warm-400 hover:bg-navy-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<span className="text-base leading-none">{lang.flag}</span>
|
|
<span className="hidden sm:inline">{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Auth buttons */}
|
|
<div>
|
|
{user ? (
|
|
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
|
<span
|
|
className="min-w-0 text-sm text-warm-300 truncate"
|
|
aria-label={user.email}
|
|
title={user.email}
|
|
>
|
|
{emailDomain ? (
|
|
<>
|
|
<span aria-hidden="true">{emailLocal}</span>
|
|
<span aria-hidden="true" className="after:content-['@']" />
|
|
<span aria-hidden="true">{emailDomain}</span>
|
|
</>
|
|
) : (
|
|
user.email
|
|
)}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
onLogout();
|
|
onClose();
|
|
}}
|
|
className="shrink-0 cursor-pointer text-sm text-warm-400 hover:text-white"
|
|
>
|
|
{t('userMenu.logOut')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
onLoginClick();
|
|
onClose();
|
|
}}
|
|
className="flex-1 cursor-pointer px-3 py-2 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
|
|
>
|
|
{t('header.logIn')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
onRegisterClick();
|
|
onClose();
|
|
}}
|
|
className="flex-1 cursor-pointer px-3 py-2 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
|
|
>
|
|
{t('header.createAccount')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|