ui(nav): simplify mobile bar to map, three tabs and more

This commit is contained in:
SkateSpots 2026-10-30 20:25:52 +07:00
parent 344b41afc8
commit 23929653d3
3 changed files with 33 additions and 78 deletions

View file

@ -7,10 +7,10 @@ import {
DEFAULT_TAB_IDS, DEFAULT_TAB_IDS,
type NavTab, type NavTab,
} from '@/hooks/useNavPreferences'; } from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react'; import { MapPin, MoreHorizontal, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
interface CustomizeNavSheetProps { interface CustomizeNavSheetProps {
open: boolean; open: boolean;
@ -20,14 +20,14 @@ interface CustomizeNavSheetProps {
/** /**
* Bottom-sheet editor for the mobile nav bar. * Bottom-sheet editor for the mobile nav bar.
* *
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right). * Layout model: Map (fixed left) · 3 user-picked · More (fixed right).
* The user toggles up to 3 destinations from the catalog and can reorder them. * The user toggles up to 3 destinations from the catalog and can reorder them.
* Draft state is local until "Done", so changes feel deliberate and cancelable. * Draft state is local until "Done", so changes feel deliberate and cancelable.
*/ */
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) { export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences(); const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
const { user } = useCurrentUser();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser();
// Local draft so we can cancel without persisting. // Local draft so we can cancel without persisting.
const [draft, setDraft] = useState<string[]>(middleIds); const [draft, setDraft] = useState<string[]>(middleIds);
@ -115,8 +115,8 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
} }
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />; return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})} })}
{/* Fixed Profile */} {/* Fixed More */}
<PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" /> <PreviewSlot icon={MoreHorizontal} label={t('nav.more')} locked accent="#ff0040" />
</div> </div>
</div> </div>

View file

@ -367,21 +367,17 @@ function MobileHeaderUserMenu({
function MobileNav({ function MobileNav({
isActive, isActive,
isLoggedIn, isLoggedIn,
userPubkey,
userAvatar,
userName,
t, t,
onCustomize, onCustomize,
onMore,
fixedMapTab, fixedMapTab,
middleTabs, middleTabs,
}: { }: {
isActive: (path: string) => boolean; isActive: (path: string) => boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
userPubkey?: string;
userAvatar?: string;
userName?: string;
t: (key: string) => string; t: (key: string) => string;
onCustomize: () => void; onCustomize: () => void;
onMore: () => void;
fixedMapTab: NavTab; fixedMapTab: NavTab;
middleTabs: NavTab[]; middleTabs: NavTab[];
}) { }) {
@ -483,60 +479,21 @@ function MobileNav({
{/* CUSTOM: 3 middle tabs */} {/* CUSTOM: 3 middle tabs */}
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))} {resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
{/* FIXED: Profile / Login tab. {/* FIXED: More — the burger menu owns profile, login, wallet,
Logged in → your public profile (View Profile). settings and every secondary section. */}
Logged out → Settings (login lives there). <button
Long-press / right-click still opens nav customization. */} type="button"
{(() => { onClick={onMore}
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings'; className="group relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-1 px-1 py-1.5 active:scale-90 transition-transform duration-150 text-gray-500"
const tabActive = isActive(profileHref); aria-label={t('nav.more')}
return ( >
<Link <span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
to={profileHref} <MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
onTouchStart={startPress} </span>
onTouchEnd={cancelPress} <span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
onTouchMove={cancelPress} {t('nav.more')}
onContextMenu={(e) => { </span>
e.preventDefault(); </button>
onCustomize();
}}
className={cn(
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
)}
>
<span
className={cn(
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
)}
>
{isLoggedIn && userPubkey ? (
<div className="relative">
<Avatar className={cn(
'h-[24px] w-[24px] border-2 transition-transform duration-300',
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
)}>
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
{(userName || '??').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
</div>
) : (
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
)}
</span>
<span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
tabActive ? 'font-extrabold' : 'font-semibold',
)}>
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
</span>
</Link>
);
})()}
</div> </div>
</div> </div>
</nav> </nav>
@ -618,7 +575,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts(); const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey); const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
const userAvatar = author.data?.metadata?.picture;
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences(); const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
const { const {
primaryTabs: desktopPrimaryTabs, primaryTabs: desktopPrimaryTabs,
@ -1283,10 +1239,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
<MobileNav <MobileNav
isActive={isActive} isActive={isActive}
onMore={() => setMobileMenuOpen(true)}
isLoggedIn={!!user} isLoggedIn={!!user}
userPubkey={user?.pubkey}
userAvatar={userAvatar}
userName={displayName}
t={t} t={t}
onCustomize={() => setShowCustomizeNav(true)} onCustomize={() => setShowCustomizeNav(true)}
fixedMapTab={fixedMapTab} fixedMapTab={fixedMapTab}

View file

@ -23,8 +23,8 @@ import { usePreferences } from '@/hooks/usePreferences';
* Bottom-nav customization (mobile). * Bottom-nav customization (mobile).
* *
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a * Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
* FIXED Profile/Login slot on the right. The user customizes the **3 middle * FIXED More button on the right. The user customizes the **3 middle slots**
* slots** from a curated catalog. Anything they don't pin stays reachable in the * from a curated catalog. Profile/login lives in the header avatar menu. Anything they don't pin stays reachable in the
* burger menu, so nothing is ever lost — just re-prioritized. * burger menu, so nothing is ever lost — just re-prioritized.
* *
* Stored per-device in localStorage (v1). Could later sync across devices via a * Stored per-device in localStorage (v1). Could later sync across devices via a
@ -55,7 +55,7 @@ export const FIXED_MAP_TAB: NavTab = {
/** /**
* Everything the user can pin to the 3 middle slots. * Everything the user can pin to the 3 middle slots.
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.) * (Map is excluded — it's the fixed anchor. More is the fixed right slot.)
*/ */
export const NAV_CATALOG: NavTab[] = [ export const NAV_CATALOG: NavTab[] = [
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap }, { id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
@ -75,11 +75,12 @@ export const NAV_CATALOG: NavTab[] = [
]; ];
/** Number of user-customizable middle slots. */ /** Number of user-customizable middle slots. */
export const MIDDLE_SLOTS = 4; export const MIDDLE_SLOTS = 3;
/** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable /** Default middle tabs. Keep the mobile bar to five intents total:
* from the phone nav out of the box; users can swap any of them via long-press. */ * fixed Map + these three + a fixed More button. Everything else lives in the
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints']; * burger menu, and users can still swap any middle tab via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1'; const STORAGE_KEY = 'skatespots:nav-tabs:v1';
@ -90,7 +91,7 @@ const STORAGE_KEY = 'skatespots:nav-tabs:v1';
// ever lost. Map is always the first primary tab and cannot be hidden/moved. // ever lost. Map is always the first primary tab and cannot be hidden/moved.
/** Default desktop primary tabs. */ /** Default desktop primary tabs. */
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints']; export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'community'];
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1'; const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';