Customizable mobile bottom nav: fixed Map + 3 user-picked tabs + fixed Profile

- Add useNavPreferences hook (localStorage) with a curated catalog of pinnable
  destinations (Lab, Events, Crews, Wallet, Stash, Collections, Contacts,
  Messages, Stats), defaults, normalization/validation and login-gating
- Map (Shops & Spots) is fixed on the left; Profile/Login fixed on the right;
  the 3 middle slots are user-customizable — nothing is ever lost since the
  burger menu still lists everything
- CustomizeNavSheet: bottom-sheet editor with a live preview bar (locked slots
  shown with 🔒), a toggle grid with a 3/3 counter + "bar full" state, up/down
  reordering, and reset-to-default. Draft state so changes are cancelable
- Entry points: long-press (or right-click) any bottom tab, plus a "Customize
  nav bar" item in the burger menu
- Login-gated tabs are hidden from logged-out users and gracefully fall back if
  a saved layout references them
- Resolve tabs in Layout and pass to MobileNav as props (avoids any module-init
  ordering issues with the hoisted MobileNav declaration); defensive ?? [] guard

Docs unchanged (client-only preference, no new Nostr kinds).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-25 12:53:06 +07:00
parent 6848a72869
commit d7af2626ff
3 changed files with 497 additions and 50 deletions

View file

@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
useNavPreferences,
MIDDLE_SLOTS,
type NavTab,
} from '@/hooks/useNavPreferences';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
import { cn } from '@/lib/utils';
import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react';
interface CustomizeNavSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Bottom-sheet editor for the mobile nav bar.
*
* Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right).
* 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.
*/
export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) {
const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences();
const { user } = useCurrentUser();
const { t } = useTranslation();
// Local draft so we can cancel without persisting.
const [draft, setDraft] = useState<string[]>(middleIds);
// Re-seed the draft whenever the sheet opens.
useEffect(() => {
if (open) setDraft(middleIds);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const isLoggedIn = !!user;
const full = draft.length >= MIDDLE_SLOTS;
const label = (tab: NavTab) => (tab.labelKey ? t(tab.labelKey) : tab.label);
const toggle = (id: string) => {
setDraft((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= MIDDLE_SLOTS) return prev; // full — ignore
return [...prev, id];
});
};
const move = (id: string, dir: -1 | 1) => {
setDraft((prev) => {
const idx = prev.indexOf(id);
const next = idx + dir;
if (idx < 0 || next < 0 || next >= prev.length) return prev;
const copy = [...prev];
[copy[idx], copy[next]] = [copy[next], copy[idx]];
return copy;
});
};
const save = () => {
setMiddleIds(draft);
onOpenChange(false);
};
const handleReset = () => {
reset();
// reset() writes defaults; reseed draft from the same default source.
setDraft(['lab', 'events', 'crews']);
};
// Resolve draft ids to tabs for the preview & reorder list.
const draftTabs = draft
.map((id) => catalog.find((c) => c.id === id))
.filter((c): c is NavTab => !!c);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
className="max-h-[90vh] overflow-y-auto rounded-t-3xl border-t border-white/10 bg-black/95 backdrop-blur"
>
<SheetHeader className="text-left">
<SheetTitle className="flex items-center gap-2 text-xl font-black uppercase text-[#ff0040]">
<Sparkles className="h-5 w-5" /> Customize your bar
</SheetTitle>
<SheetDescription className="text-gray-400">
Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu.
</SheetDescription>
</SheetHeader>
{/* Live preview */}
<div className="mt-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">Preview</p>
<div className="glass flex items-stretch justify-around gap-1 rounded-2xl border border-white/10 p-2">
{/* Fixed Map */}
<PreviewSlot icon={MapPin} label={t('nav.map')} locked accent="#ff0040" />
{/* Middle draft */}
{Array.from({ length: MIDDLE_SLOTS }).map((_, i) => {
const tab = draftTabs[i];
if (!tab) {
return (
<div
key={`empty-${i}`}
className="flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/10 py-1.5 text-gray-700"
>
<span className="text-lg leading-none">+</span>
<span className="text-[9px] font-semibold uppercase">Empty</span>
</div>
);
}
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})}
{/* Fixed Profile */}
<PreviewSlot icon={User} label={isLoggedIn ? 'Profile' : 'Log In'} locked accent="#ff0040" />
</div>
</div>
{/* Reorder chosen tabs */}
{draftTabs.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Order ({draft.length}/{MIDDLE_SLOTS})
</p>
<div className="space-y-1.5">
{draftTabs.map((tab, i) => (
<div
key={tab.id}
className="flex items-center gap-3 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/[0.06] p-2.5"
>
<tab.icon className="h-4 w-4 text-[#ff0040]" />
<span className="flex-1 text-sm font-bold text-white">{label(tab)}</span>
<button
type="button"
onClick={() => move(tab.id, -1)}
disabled={i === 0}
aria-label={`Move ${label(tab)} up`}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => move(tab.id, 1)}
disabled={i === draftTabs.length - 1}
aria-label={`Move ${label(tab)} down`}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
>
<ArrowDown className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Catalog picker */}
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Available tabs</p>
{full && (
<span className="text-[10px] font-bold text-amber-400">
Bar full — remove one to swap
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{catalog.map((tab) => {
const selected = draft.includes(tab.id);
const gatedOut = tab.authOnly && !isLoggedIn;
const disabled = gatedOut || (!selected && full);
return (
<button
key={tab.id}
type="button"
onClick={() => !gatedOut && toggle(tab.id)}
disabled={disabled}
aria-pressed={selected}
className={cn(
'relative flex flex-col items-center justify-center gap-1.5 rounded-2xl border p-3 transition-all active:scale-95',
selected
? 'border-[#ff0040] bg-[#ff0040]/15 text-white shadow-[0_0_12px_rgba(255,0,64,0.25)]'
: 'border-white/10 bg-white/[0.03] text-gray-300',
!selected && !disabled && 'hover:border-[#ff0040]/40 hover:text-white',
disabled && 'cursor-not-allowed opacity-40',
)}
>
{selected && (
<span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#ff0040]">
<Check className="h-2.5 w-2.5 text-white" />
</span>
)}
<tab.icon className={cn('h-5 w-5', selected ? 'text-[#ff0040]' : '')} />
<span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span>
{gatedOut && (
<span className="flex items-center gap-0.5 text-[8px] font-bold text-gray-500">
<Lock className="h-2 w-2" /> Log in
</span>
)}
</button>
);
})}
</div>
</div>
{/* Footer actions */}
<div className="mt-6 flex items-center gap-3 pb-2">
<Button
variant="outline"
onClick={handleReset}
className="gap-2 border-white/15 text-gray-300 hover:text-white"
>
<RotateCcw className="h-4 w-4" /> Reset
</Button>
<Button
onClick={save}
className="flex-1 gap-2 bg-[#ff0040] font-black uppercase text-white hover:bg-[#ff0040]/90"
>
<Check className="h-4 w-4" /> Done
</Button>
</div>
</SheetContent>
</Sheet>
);
}
function PreviewSlot({
icon: Icon,
label,
locked,
accent,
}: {
icon: NavTab['icon'];
label: string;
locked?: boolean;
accent: string;
}) {
return (
<div className="relative flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5">
{locked && (
<span className="absolute right-0.5 top-0 text-gray-600">
<Lock className="h-2.5 w-2.5" />
</span>
)}
<Icon className="h-[22px] w-[22px]" style={{ color: locked ? accent : accent }} />
<span className="text-[9px] font-semibold uppercase leading-tight text-gray-300">{label}</span>
</div>
);
}

View file

@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CustomizeNavSheet } from '@/components/CustomizeNavSheet';
import { useNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet';
@ -46,6 +48,7 @@ import {
CreditCard, CreditCard,
Radio, Radio,
Users, Users,
SlidersHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -233,6 +236,9 @@ function MobileNav({
userAvatar, userAvatar,
userName, userName,
t, t,
onCustomize,
fixedMapTab,
middleTabs,
}: { }: {
isActive: (path: string) => boolean; isActive: (path: string) => boolean;
cashuBalance: number; cashuBalance: number;
@ -241,45 +247,61 @@ function MobileNav({
userAvatar?: string; userAvatar?: string;
userName?: string; userName?: string;
t: (key: string) => string; t: (key: string) => string;
onCustomize: () => void;
fixedMapTab: NavTab;
middleTabs: NavTab[];
}) { }) {
// Decide which tabs to show — max 5 on small screens // Bar model: FIXED Map · 3 user-picked middle tabs · FIXED Profile.
// Logged in: Home, Map, Lab, Events, Profile (wallet in profile dropdown) // (tabs are resolved in Layout and passed in to avoid any module-init/TDZ
// Logged out: Home, Map, Lab, Events, Login // ordering issues with the hoisted function declaration.)
const tabs = [
...navItemKeys,
...(isLoggedIn
? [{ href: '/wallet', labelKey: 'nav.wallet', icon: Wallet }]
: []),
];
return ( // Middle tabs the user pinned. If a login-gated tab is pinned while logged
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden"> // out, fall back to a sensible public tab so the slot is never dead.
{/* Nav bar */} const resolvedMiddle = (middleTabs ?? []).map((tab) => {
<div if (tab.authOnly && !isLoggedIn) {
className="glass border-t border-white/10" return { ...tab, href: '/lab', label: 'The Lab', labelKey: 'nav.lab' };
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', paddingLeft: 'env(safe-area-inset-left, 0px)', paddingRight: 'env(safe-area-inset-right, 0px)' }} }
> return tab;
<div className="flex items-stretch justify-around px-1 pt-1 pb-1"> });
{tabs.map((item) => {
const active = isActive(item.href); // Long-press (touch) / right-click opens the customization sheet.
const isWallet = item.href === '/wallet'; const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPress = () => {
pressTimer.current = setTimeout(() => {
onCustomize();
pressTimer.current = null;
}, 500);
};
const cancelPress = () => {
if (pressTimer.current) {
clearTimeout(pressTimer.current);
pressTimer.current = null;
}
};
const renderTab = (tab: NavTab, key: string) => {
const active = isActive(tab.href);
const isWallet = tab.id === 'wallet';
const accent = isWallet ? 'text-orange-400' : 'text-[#ff0040]';
const dot = isWallet ? 'bg-orange-400' : 'bg-[#ff0040]';
return ( return (
<Link <Link
key={item.href} key={key}
to={item.href} to={tab.href}
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
onContextMenu={(e) => {
e.preventDefault();
onCustomize();
}}
className={cn( className={cn(
'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90', 'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90',
active active ? accent : 'text-gray-500',
? isWallet ? 'text-orange-400' : 'text-[#ff0040]'
: 'text-gray-500'
)} )}
> >
{/* Active indicator dot */}
{active && ( {active && (
<span className={cn( <span className={cn('absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full', dot)} />
'absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full',
isWallet ? 'bg-orange-400' : 'bg-[#ff0040]'
)} />
)} )}
{isWallet ? ( {isWallet ? (
<div className="relative"> <div className="relative">
@ -291,18 +313,39 @@ function MobileNav({
)} )}
</div> </div>
) : ( ) : (
<item.icon className={cn('h-[22px] w-[22px] transition-transform', active && 'scale-110')} /> <tab.icon className={cn('h-[22px] w-[22px] transition-transform', active && 'scale-110')} />
)} )}
<span className="text-[9px] xs:text-[10px] font-semibold leading-tight tracking-wide uppercase"> <span className="text-[9px] xs:text-[10px] font-semibold leading-tight tracking-wide uppercase">
{t(item.labelKey)} {tab.labelKey ? t(tab.labelKey) : tab.label}
</span> </span>
</Link> </Link>
); );
})} };
{/* Profile / Login tab — always lands on Settings root */} return (
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
{/* Nav bar */}
<div
className="glass border-t border-white/10"
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', paddingLeft: 'env(safe-area-inset-left, 0px)', paddingRight: 'env(safe-area-inset-right, 0px)' }}
>
<div className="flex items-stretch justify-around px-1 pt-1 pb-1">
{/* FIXED: Map / Shops & Spots */}
{renderTab(fixedMapTab, 'fixed-map')}
{/* CUSTOM: 3 middle tabs */}
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
{/* FIXED: Profile / Login tab — always lands on Settings root */}
<Link <Link
to="/settings" to="/settings"
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
onContextMenu={(e) => {
e.preventDefault();
onCustomize();
}}
className={cn( className={cn(
'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90', 'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90',
isActive('/settings') ? 'text-[#ff0040]' : 'text-gray-500' isActive('/settings') ? 'text-[#ff0040]' : 'text-gray-500'
@ -341,6 +384,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showStatusDialog, setShowStatusDialog] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false);
const [showCustomizeNav, setShowCustomizeNav] = useState(false);
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide(); const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu(); const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
@ -349,6 +393,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
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 userAvatar = author.data?.metadata?.picture;
const { fixedMapTab, middleTabs } = useNavPreferences();
const isActive = (path: string) => { const isActive = (path: string) => {
// Both / and /map are the "spots" page // Both / and /map are the "spots" page
@ -711,6 +756,18 @@ export function Layout({ children, noPadding }: LayoutProps) {
isActive={isActive('/settings')} isActive={isActive('/settings')}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
{/* Customize the bottom nav bar */}
<button
type="button"
onClick={() => {
setMobileMenuOpen(false);
setShowCustomizeNav(true);
}}
className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 text-muted-foreground hover:bg-muted hover:text-foreground text-left"
>
<SlidersHorizontal className="h-5 w-5" />
<span className="font-medium">Customize nav bar</span>
</button>
</div> </div>
{/* Language Toggle in Mobile Menu */} {/* Language Toggle in Mobile Menu */}
<div className="py-3 border-t"> <div className="py-3 border-t">
@ -834,8 +891,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
userAvatar={userAvatar} userAvatar={userAvatar}
userName={displayName} userName={displayName}
t={t} t={t}
onCustomize={() => setShowCustomizeNav(true)}
fixedMapTab={fixedMapTab}
middleTabs={middleTabs}
/> />
{/* Bottom-nav customization sheet (mobile) */}
<CustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
{/* Sasquatch Guide (all screens) */} {/* Sasquatch Guide (all screens) */}
<SasquatchGuide <SasquatchGuide
message={currentMessage} message={currentMessage}

View file

@ -0,0 +1,132 @@
import { useCallback, useMemo } from 'react';
import {
MapPin,
GraduationCap,
Calendar,
Users,
Wallet,
Package,
Bookmark,
MessageCircle,
BarChart3,
type LucideIcon,
} from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
/**
* Bottom-nav customization (mobile).
*
* 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
* slots** from a curated catalog. Anything they don't pin stays reachable in the
* burger menu, so nothing is ever lost — just re-prioritized.
*
* Stored per-device in localStorage (v1). Could later sync across devices via a
* NIP-78 app-data event without changing this interface.
*/
export interface NavTab {
/** Stable id used in storage. */
id: string;
href: string;
/** Translation key when available, else falls back to `label`. */
labelKey?: string;
/** Plain label fallback / display name in the editor. */
label: string;
icon: LucideIcon;
/** Requires the user to be logged in to be useful. */
authOnly?: boolean;
}
/** The Map tab is fixed on the far left and never customizable. */
export const FIXED_MAP_TAB: NavTab = {
id: 'map',
href: '/',
labelKey: 'nav.map',
label: 'Spots',
icon: MapPin,
};
/**
* Everything the user can pin to the 3 middle slots.
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.)
*/
export const NAV_CATALOG: NavTab[] = [
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
{ id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar },
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
{ id: 'messages', href: '/messages', label: 'Messages', icon: MessageCircle, authOnly: true },
{ id: 'stats', href: '/stats', labelKey: 'nav.stats', label: 'Stats', icon: BarChart3 },
];
/** Number of user-customizable middle slots. */
export const MIDDLE_SLOTS = 3;
/** Default middle tabs (matches the app's original hardcoded bar). */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
function lookup(id: string): NavTab | undefined {
return NAV_CATALOG.find((t) => t.id === id);
}
/** Sanitize stored ids: keep known ids, dedupe, clamp to MIDDLE_SLOTS, backfill. */
function normalizeIds(ids: string[]): string[] {
const seen = new Set<string>();
const clean: string[] = [];
for (const id of ids) {
if (lookup(id) && !seen.has(id)) {
seen.add(id);
clean.push(id);
}
if (clean.length >= MIDDLE_SLOTS) break;
}
// Backfill from defaults if the user somehow has fewer than the slot count.
for (const id of DEFAULT_TAB_IDS) {
if (clean.length >= MIDDLE_SLOTS) break;
if (!seen.has(id)) {
seen.add(id);
clean.push(id);
}
}
return clean.slice(0, MIDDLE_SLOTS);
}
export function useNavPreferences() {
const [storedIds, setStoredIds] = useLocalStorage<string[]>(STORAGE_KEY, DEFAULT_TAB_IDS);
const middleIds = useMemo(() => normalizeIds(storedIds), [storedIds]);
/** Resolve ids → NavTab objects (order preserved). */
const middleTabs = useMemo(
() => middleIds.map(lookup).filter((t): t is NavTab => !!t),
[middleIds],
);
const setMiddleIds = useCallback(
(ids: string[]) => setStoredIds(normalizeIds(ids)),
[setStoredIds],
);
const reset = useCallback(() => setStoredIds(DEFAULT_TAB_IDS), [setStoredIds]);
return {
/** The 3 chosen middle tab ids. */
middleIds,
/** The 3 chosen middle tabs resolved to objects. */
middleTabs,
/** Replace the whole selection (auto-normalized). */
setMiddleIds,
/** Restore defaults. */
reset,
/** Full catalog for the editor. */
catalog: NAV_CATALOG,
fixedMapTab: FIXED_MAP_TAB,
slots: MIDDLE_SLOTS,
};
}