Add desktop top-nav customization (Settings > Appearance > Navigation)
Option B: a lightweight reorder / show-hide list for the desktop header. - useDesktopNavPreferences: separate localStorage-backed preference for which catalog items appear as primary top-nav tabs and their order (Map fixed first, no hard slot cap). Reuses the shared NAV_CATALOG. - DesktopNavManager: settings UI to reorder primary tabs (up/down) and toggle items between the top bar and the "More" menu; Map locked; reset to default. - Layout desktop header now renders fixed Map + user's primary tabs, and the "More" dropdown dynamically lists the hidden catalog items (auth-gated), so each item appears in exactly one place. - Manager shown only on md+ screens (mobile uses the bottom-bar customizer). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
834f33d53e
commit
148ae251ef
4 changed files with 273 additions and 34 deletions
157
src/components/DesktopNavManager.tsx
Normal file
157
src/components/DesktopNavManager.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
||||||
|
import { useTranslation } from '@/contexts/LanguageContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ArrowUp, ArrowDown, Eye, EyeOff, Lock, RotateCcw, Monitor } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop top-nav customizer (Settings).
|
||||||
|
*
|
||||||
|
* A lightweight list: reorder the primary top-nav tabs with up/down controls
|
||||||
|
* and toggle items shown/hidden. Map is the fixed first tab (locked). Hidden
|
||||||
|
* items still live in the header's "More" dropdown, so nothing is ever lost.
|
||||||
|
* Persists via useDesktopNavPreferences (localStorage).
|
||||||
|
*/
|
||||||
|
export function DesktopNavManager() {
|
||||||
|
const { primaryIds, hiddenIds, setPrimaryIds, reset, catalog, fixedMapTab } =
|
||||||
|
useDesktopNavPreferences();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const label = (tab: NavTab) => (tab.labelKey ? t(tab.labelKey) : tab.label);
|
||||||
|
const byId = (id: string) => catalog.find((c) => c.id === id);
|
||||||
|
|
||||||
|
const move = (id: string, dir: -1 | 1) => {
|
||||||
|
const idx = primaryIds.indexOf(id);
|
||||||
|
const next = idx + dir;
|
||||||
|
if (idx < 0 || next < 0 || next >= primaryIds.length) return;
|
||||||
|
const copy = [...primaryIds];
|
||||||
|
[copy[idx], copy[next]] = [copy[next], copy[idx]];
|
||||||
|
setPrimaryIds(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = (id: string) => setPrimaryIds(primaryIds.filter((x) => x !== id));
|
||||||
|
const show = (id: string) => setPrimaryIds([...primaryIds, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-400">
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base font-bold text-white">Desktop navigation</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose which tabs appear in the top bar and their order. Hidden tabs
|
||||||
|
still live in the “More” menu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Map tab */}
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-red-500/25 bg-red-500/[0.06] p-3">
|
||||||
|
<fixedMapTab.icon className="h-4 w-4 text-red-400" />
|
||||||
|
<span className="flex-1 text-sm font-bold text-white">{label(fixedMapTab)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||||
|
<Lock className="h-3 w-3" /> Fixed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shown (primary) tabs — reorderable */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
Shown in top bar ({primaryIds.length})
|
||||||
|
</p>
|
||||||
|
{primaryIds.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-dashed border-white/10 p-3 text-xs text-gray-500">
|
||||||
|
No extra tabs shown — everything is in the “More” menu. Add some below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{primaryIds.map((id, i) => {
|
||||||
|
const tab = byId(id);
|
||||||
|
if (!tab) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-2.5"
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4 text-red-400" />
|
||||||
|
<span className="flex-1 truncate text-sm font-bold text-white">{label(tab)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(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(id, 1)}
|
||||||
|
disabled={i === primaryIds.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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => hide(id)}
|
||||||
|
aria-label={`Hide ${label(tab)}`}
|
||||||
|
title="Move to More menu"
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-amber-400"
|
||||||
|
>
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden tabs — available to add */}
|
||||||
|
{hiddenIds.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
In “More” menu ({hiddenIds.length})
|
||||||
|
</p>
|
||||||
|
{hiddenIds.map((id) => {
|
||||||
|
const tab = byId(id);
|
||||||
|
if (!tab) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-xl border border-white/5 bg-black/30 p-2.5 opacity-70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate text-sm font-semibold text-gray-400">
|
||||||
|
{label(tab)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => show(id)}
|
||||||
|
aria-label={`Show ${label(tab)} in top bar`}
|
||||||
|
title="Show in top bar"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-xs font-bold text-gray-300 transition-colors hover:border-red-500/40 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" /> Show
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={reset}
|
||||||
|
className="gap-2 border-white/15 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" /> Reset to default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ 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 { CustomizeNavSheet } from '@/components/CustomizeNavSheet';
|
||||||
import { useNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
import { useNavPreferences, useDesktopNavPreferences, 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';
|
||||||
|
|
@ -394,6 +394,12 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
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 { fixedMapTab, middleTabs } = useNavPreferences();
|
||||||
|
const {
|
||||||
|
primaryTabs: desktopPrimaryTabs,
|
||||||
|
hiddenIds: desktopHiddenIds,
|
||||||
|
fixedMapTab: desktopMapTab,
|
||||||
|
catalog: desktopCatalog,
|
||||||
|
} = useDesktopNavPreferences();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
// Both / and /map are the "spots" page
|
// Both / and /map are the "spots" page
|
||||||
|
|
@ -418,9 +424,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{navItemKeys.map((item) => (
|
{/* Fixed Map tab first, then the user's chosen primary tabs */}
|
||||||
|
{[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.id}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
|
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
|
||||||
|
|
@ -430,7 +437,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{t(item.labelKey)}
|
{item.labelKey ? t(item.labelKey) : item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -451,36 +458,21 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
{user && (
|
{/* Catalog items the user hasn't pinned to the top bar.
|
||||||
<DropdownMenuItem asChild>
|
Auth-only items (stash/collections/contacts/wallet/messages)
|
||||||
<Link to="/contacts" className="flex items-center gap-2 cursor-pointer">
|
only show when logged in. */}
|
||||||
<Users className="h-4 w-4 text-teal-400" />
|
{desktopHiddenIds
|
||||||
<span className="text-teal-400">Contacts</span>
|
.map((id) => desktopCatalog.find((c) => c.id === id))
|
||||||
</Link>
|
.filter((tab): tab is NavTab => !!tab)
|
||||||
</DropdownMenuItem>
|
.filter((tab) => !tab.authOnly || !!user)
|
||||||
)}
|
.map((tab) => (
|
||||||
{user && (
|
<DropdownMenuItem asChild key={tab.id}>
|
||||||
<DropdownMenuItem asChild>
|
<Link to={tab.href} className="flex items-center gap-2 cursor-pointer">
|
||||||
<Link to="/collections" className="flex items-center gap-2 cursor-pointer">
|
<tab.icon className="h-4 w-4" />
|
||||||
<Bookmark className="h-4 w-4" />
|
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||||||
My Collections
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{user && (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<Package className="h-4 w-4 text-purple-400" />
|
|
||||||
<span className="text-purple-400">My Stash</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/stats" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
{t('nav.stats')}
|
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/about" className="flex items-center gap-2 cursor-pointer">
|
<Link to="/about" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,34 @@ export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
|
||||||
|
|
||||||
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
||||||
|
|
||||||
|
// ─── Desktop top-nav customization (Option B) ────────────────────────────────
|
||||||
|
// Unlike mobile (fixed 5 slots), the desktop header has room. Here the user
|
||||||
|
// chooses which catalog items appear as PRIMARY tabs in the top bar and in what
|
||||||
|
// order; anything not shown still lives in the "More" dropdown, so nothing is
|
||||||
|
// ever lost. Map is always the first primary tab and cannot be hidden/moved.
|
||||||
|
|
||||||
|
/** Default desktop primary tabs (matches the original hardcoded header). */
|
||||||
|
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews'];
|
||||||
|
|
||||||
|
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize desktop ids: keep known catalog ids, dedupe. Unlike mobile there is
|
||||||
|
* no hard slot cap — the header can show as many as the user wants (they can
|
||||||
|
* always hide clutter). Falls back to defaults only when the list is empty.
|
||||||
|
*/
|
||||||
|
function normalizeDesktopIds(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
function lookup(id: string): NavTab | undefined {
|
function lookup(id: string): NavTab | undefined {
|
||||||
return NAV_CATALOG.find((t) => t.id === id);
|
return NAV_CATALOG.find((t) => t.id === id);
|
||||||
}
|
}
|
||||||
|
|
@ -130,3 +158,54 @@ export function useNavPreferences() {
|
||||||
slots: MIDDLE_SLOTS,
|
slots: MIDDLE_SLOTS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop top-nav customization. The user picks which catalog items show as
|
||||||
|
* primary tabs (and their order); the rest live in "More". Map is always the
|
||||||
|
* fixed first tab. Persisted per-device in localStorage.
|
||||||
|
*/
|
||||||
|
export function useDesktopNavPreferences() {
|
||||||
|
const [storedIds, setStoredIds] = useLocalStorage<string[]>(
|
||||||
|
DESKTOP_STORAGE_KEY,
|
||||||
|
DEFAULT_DESKTOP_IDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryIds = useMemo(() => normalizeDesktopIds(storedIds), [storedIds]);
|
||||||
|
|
||||||
|
const primaryTabs = useMemo(
|
||||||
|
() => primaryIds.map(lookup).filter((t): t is NavTab => !!t),
|
||||||
|
[primaryIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Catalog ids NOT shown as primary tabs (these fall into the More menu). */
|
||||||
|
const hiddenIds = useMemo(
|
||||||
|
() => NAV_CATALOG.map((t) => t.id).filter((id) => !primaryIds.includes(id)),
|
||||||
|
[primaryIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPrimaryIds = useCallback(
|
||||||
|
(ids: string[]) => setStoredIds(normalizeDesktopIds(ids)),
|
||||||
|
[setStoredIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(
|
||||||
|
() => setStoredIds(DEFAULT_DESKTOP_IDS),
|
||||||
|
[setStoredIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Ordered ids shown as primary desktop tabs. */
|
||||||
|
primaryIds,
|
||||||
|
/** Resolved primary tab objects (order preserved). */
|
||||||
|
primaryTabs,
|
||||||
|
/** Catalog ids not shown as primary (live in More). */
|
||||||
|
hiddenIds,
|
||||||
|
/** Replace the whole primary selection (auto-normalized). */
|
||||||
|
setPrimaryIds,
|
||||||
|
/** Restore defaults. */
|
||||||
|
reset,
|
||||||
|
/** Full catalog for the editor. */
|
||||||
|
catalog: NAV_CATALOG,
|
||||||
|
fixedMapTab: FIXED_MAP_TAB,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { WalletModal } from '@/components/WalletModal';
|
||||||
import { SecuritySettings } from '@/components/SecuritySettings';
|
import { SecuritySettings } from '@/components/SecuritySettings';
|
||||||
import { RelatrSettings } from '@/components/RelatrSettings';
|
import { RelatrSettings } from '@/components/RelatrSettings';
|
||||||
import { LanguageSelector } from '@/components/LanguageToggle';
|
import { LanguageSelector } from '@/components/LanguageToggle';
|
||||||
|
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
||||||
import { MyDataTab } from '@/components/MyDataTab';
|
import { MyDataTab } from '@/components/MyDataTab';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/LanguageContext';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
@ -312,6 +313,16 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange.
|
SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop top-nav customization (Option B). Only relevant on wider
|
||||||
|
screens, so hide the control on mobile where the bottom-bar
|
||||||
|
customizer is used instead. */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">Navigation</p>
|
||||||
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
||||||
|
<DesktopNavManager />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue