From 148ae251ef62ee2b3b9c3c87e450da286b6cec72 Mon Sep 17 00:00:00 2001
From: "shakespeare.diy"
Date: Wed, 29 Jul 2026 22:09:20 +0700
Subject: [PATCH] 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
---
src/components/DesktopNavManager.tsx | 157 +++++++++++++++++++++++++++
src/components/Layout.tsx | 60 +++++-----
src/hooks/useNavPreferences.ts | 79 ++++++++++++++
src/pages/SettingsPage.tsx | 11 ++
4 files changed, 273 insertions(+), 34 deletions(-)
create mode 100644 src/components/DesktopNavManager.tsx
diff --git a/src/components/DesktopNavManager.tsx b/src/components/DesktopNavManager.tsx
new file mode 100644
index 0000000..73c0416
--- /dev/null
+++ b/src/components/DesktopNavManager.tsx
@@ -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 (
+
+
+
+
+
+
+
Desktop navigation
+
+ Choose which tabs appear in the top bar and their order. Hidden tabs
+ still live in the “More” menu.
+
+
+
+
+ {/* Fixed Map tab */}
+
+
+ {label(fixedMapTab)}
+
+ Fixed
+
+
+
+ {/* Shown (primary) tabs — reorderable */}
+
+
+ Shown in top bar ({primaryIds.length})
+
+ {primaryIds.length === 0 && (
+
+ No extra tabs shown — everything is in the “More” menu. Add some below.
+
+ )}
+ {primaryIds.map((id, i) => {
+ const tab = byId(id);
+ if (!tab) return null;
+ return (
+
+
+
{label(tab)}
+
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"
+ >
+
+
+
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"
+ >
+
+
+
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"
+ >
+
+
+
+ );
+ })}
+
+
+ {/* Hidden tabs — available to add */}
+ {hiddenIds.length > 0 && (
+
+
+ In “More” menu ({hiddenIds.length})
+
+ {hiddenIds.map((id) => {
+ const tab = byId(id);
+ if (!tab) return null;
+ return (
+
+
+
+ {label(tab)}
+
+ 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"
+ >
+ Show
+
+
+ );
+ })}
+
+ )}
+
+
+
+ Reset to default
+
+
+
+ );
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 4cb1955..a5af17c 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
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 { Badge } from '@/components/ui/badge';
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 userAvatar = author.data?.metadata?.picture;
const { fixedMapTab, middleTabs } = useNavPreferences();
+ const {
+ primaryTabs: desktopPrimaryTabs,
+ hiddenIds: desktopHiddenIds,
+ fixedMapTab: desktopMapTab,
+ catalog: desktopCatalog,
+ } = useDesktopNavPreferences();
const isActive = (path: string) => {
// Both / and /map are the "spots" page
@@ -418,9 +424,10 @@ export function Layout({ children, noPadding }: LayoutProps) {
- {navItemKeys.map((item) => (
+ {/* Fixed Map tab first, then the user's chosen primary tabs */}
+ {[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
- {t(item.labelKey)}
+ {item.labelKey ? t(item.labelKey) : item.label}
))}
@@ -451,36 +458,21 @@ export function Layout({ children, noPadding }: LayoutProps) {
- {user && (
-
-
-
- Contacts
-
-
- )}
- {user && (
-
-
-
- My Collections
-
-
- )}
- {user && (
-
-
-
- My Stash
-
-
- )}
-
-
-
- {t('nav.stats')}
-
-
+ {/* Catalog items the user hasn't pinned to the top bar.
+ Auth-only items (stash/collections/contacts/wallet/messages)
+ only show when logged in. */}
+ {desktopHiddenIds
+ .map((id) => desktopCatalog.find((c) => c.id === id))
+ .filter((tab): tab is NavTab => !!tab)
+ .filter((tab) => !tab.authOnly || !!user)
+ .map((tab) => (
+
+
+
+ {tab.labelKey ? t(tab.labelKey) : tab.label}
+
+
+ ))}
diff --git a/src/hooks/useNavPreferences.ts b/src/hooks/useNavPreferences.ts
index 86fc9c9..99734ec 100644
--- a/src/hooks/useNavPreferences.ts
+++ b/src/hooks/useNavPreferences.ts
@@ -71,6 +71,34 @@ export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
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();
+ 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 {
return NAV_CATALOG.find((t) => t.id === id);
}
@@ -130,3 +158,54 @@ export function useNavPreferences() {
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(
+ 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,
+ };
+}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 423cd9b..982c2c4 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -8,6 +8,7 @@ import { WalletModal } from '@/components/WalletModal';
import { SecuritySettings } from '@/components/SecuritySettings';
import { RelatrSettings } from '@/components/RelatrSettings';
import { LanguageSelector } from '@/components/LanguageToggle';
+import { DesktopNavManager } from '@/components/DesktopNavManager';
import { MyDataTab } from '@/components/MyDataTab';
import { useTranslation } from '@/contexts/LanguageContext';
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.
+
+ {/* 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. */}
+
);