253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
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,
|
|
DEFAULT_TAB_IDS,
|
|
type NavTab,
|
|
} from '@/hooks/useNavPreferences';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { cn } from '@/lib/utils';
|
|
import { MapPin, MoreHorizontal, 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 · More (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 { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
|
|
// 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([...DEFAULT_TAB_IDS]);
|
|
};
|
|
|
|
// 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" /> {t('navSheet.title')}
|
|
</SheetTitle>
|
|
<SheetDescription className="text-gray-400">
|
|
{t('navSheet.desc', { count: MIDDLE_SLOTS })}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
{/* Live preview */}
|
|
<div className="mt-4">
|
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">{t('navSheet.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">{t('navSheet.empty')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
|
|
})}
|
|
{/* Fixed More */}
|
|
<PreviewSlot icon={MoreHorizontal} label={t('nav.more')} 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">
|
|
{t('navSheet.order', { done: draft.length, total: 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={t('navSheet.moveUp', { name: label(tab) })}
|
|
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={t('navSheet.moveDown', { name: label(tab) })}
|
|
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">{t('navSheet.available')}</p>
|
|
{full && (
|
|
<span className="text-[10px] font-bold text-amber-400">
|
|
{t('navSheet.full')}
|
|
</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" /> {t('navSheet.login')}
|
|
</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" /> {t('navSheet.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" /> {t('navSheet.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>
|
|
);
|
|
}
|