SkateSpots/src/components/DesktopNavManager.tsx
shakespeare.diy 148ae251ef 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>
2026-07-29 22:09:20 +07:00

157 lines
6.3 KiB
TypeScript

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>
);
}