Redesign mobile map controls: Layers popover + Add speed-dial FAB
Replaces the crowded 12-item vertical right rail with a clean, minimal set: - Right edge now has just two 44px controls: Near Me and a Layers button. - Layers opens a labelled popover (Spots/Shops/Gear/Tours) with color dots and checkmarks, plus a count badge on the button so state is obvious. - All "add" actions consolidated into a single red speed-dial FAB (bottom-right) that fans out to Add Spot / Add Shop / Offer Tour / Quick Snap, with a tap-catcher backdrop to dismiss. - Sasquatch bubble moved to bottom-left on mobile so it no longer competes with the Add FAB for the bottom-right corner. - Larger touch targets (44px) for better native/ZapStore ergonomics. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
d107c0a860
commit
1d1e6d18b1
2 changed files with 149 additions and 64 deletions
|
|
@ -621,7 +621,7 @@ Instructions:
|
|||
ref={dragRef}
|
||||
className={cn(
|
||||
'z-50 touch-none select-none',
|
||||
dragPos ? '' : 'fixed bottom-[9.5rem] right-4 md:bottom-6 md:right-auto md:left-4',
|
||||
dragPos ? '' : 'fixed bottom-[9.5rem] left-4 md:bottom-6 md:left-4',
|
||||
)}
|
||||
style={bubbleStyle}
|
||||
onPointerDown={onPointerDown}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Progress } from '@/components/ui/progress';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
|
|
@ -70,6 +71,7 @@ import {
|
|||
LocateOff,
|
||||
Store,
|
||||
Info,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { CameraCapture } from '@/components/CameraCapture';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
|
|
@ -585,6 +587,11 @@ export default function MapPage() {
|
|||
return () => window.removeEventListener('sasquatch:panelchange', handler);
|
||||
}, []);
|
||||
|
||||
// Mobile "Add" speed-dial (fans out Add Spot / Shop / Tour / Quick Snap)
|
||||
const [addDialOpen, setAddDialOpen] = useState(false);
|
||||
// Mobile layers popover
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
|
||||
// Quick camera capture
|
||||
const [showQuickCamera, setShowQuickCamera] = useState(false);
|
||||
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
|
||||
|
|
@ -890,6 +897,8 @@ export default function MapPage() {
|
|||
|
||||
// Count active layer filters for mobile badge
|
||||
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
|
||||
// Count of the 4 primary layers active — shown as a badge on the mobile Layers button
|
||||
const mobileLayerCount = [showSpots, showShops, showGear, showTours].filter(Boolean).length;
|
||||
const activeCategoryCount = selectedCategories.length;
|
||||
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
|
||||
|
||||
|
|
@ -1486,20 +1495,23 @@ export default function MapPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Right-side control strip — layer toggles + Near Me + Snap (mobile only) */}
|
||||
{/* ── Mobile map controls (redesigned) ──────────────────────────
|
||||
Right edge holds just TWO small controls: Near Me + Layers.
|
||||
Primary "Add" action is a speed-dial FAB (bottom-right). This
|
||||
replaces the old 12-item vertical strip. */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-all duration-200",
|
||||
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none translate-x-2" : "opacity-100 translate-x-0"
|
||||
"fixed right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-all duration-200",
|
||||
mobileFiltersOpen || selectedSpot || addDialOpen ? "opacity-0 pointer-events-none translate-x-2" : "opacity-100 translate-x-0"
|
||||
)}
|
||||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)' }}
|
||||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 6.5rem)' }}
|
||||
>
|
||||
{/* Near Me */}
|
||||
<button
|
||||
onClick={nearMeError === 'denied' ? () => setShowLocationHelp(true) : handleNearMe}
|
||||
disabled={nearMeLoading}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full flex items-center justify-center shadow-lg border transition-all touch-manipulation active:scale-90',
|
||||
'h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
|
||||
nearMeError === 'denied'
|
||||
? 'bg-red-900/80 border-red-500/30'
|
||||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||||
|
|
@ -1507,73 +1519,146 @@ export default function MapPage() {
|
|||
aria-label="Find spots near me"
|
||||
>
|
||||
{nearMeLoading ? (
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||||
<span className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||||
) : nearMeError === 'denied' ? (
|
||||
<LocateOff className="h-3.5 w-3.5 text-red-400" />
|
||||
<LocateOff className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
<LocateFixed className="h-3.5 w-3.5 text-[#ccff00]" />
|
||||
<LocateFixed className="h-4 w-4 text-[#ccff00]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-4 h-px bg-white/10" />
|
||||
|
||||
{/* Layer toggles — slim pill style */}
|
||||
{[
|
||||
{ key: 'spots', emoji: '🛹', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', emoji: '🏪', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', emoji: '🛒', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', emoji: '🗺️', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
].map(({ key, emoji, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-full text-sm flex items-center justify-center border transition-all touch-manipulation active:scale-90',
|
||||
active
|
||||
? 'bg-black/80 border-white/25 shadow-md'
|
||||
: 'bg-black/40 border-white/6 opacity-35'
|
||||
)}
|
||||
aria-label={`Toggle ${key}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Divider + Add actions + Quick Snap */}
|
||||
{user && (
|
||||
<>
|
||||
<div className="w-4 h-px bg-white/10" />
|
||||
{/* Add Spot */}
|
||||
<Link to="/create-spot" aria-label="Add a skate spot">
|
||||
<button
|
||||
className="h-9 w-9 rounded-full bg-[#ff0040]/90 border border-[#ff0040]/50 flex items-center justify-center shadow-lg shadow-[#ff0040]/20 touch-manipulation active:scale-90 transition-all"
|
||||
aria-label="Add spot"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-white" strokeWidth={2.5} />
|
||||
</button>
|
||||
</Link>
|
||||
{/* Add Shop */}
|
||||
<Link to="/create-shop" aria-label="Add a skate shop">
|
||||
<button
|
||||
className="h-9 w-9 rounded-full bg-[#00cc66]/90 border border-[#00cc66]/50 flex items-center justify-center shadow-lg shadow-[#00cc66]/20 touch-manipulation active:scale-90 transition-all"
|
||||
aria-label="Add shop"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-black" strokeWidth={2.5} />
|
||||
</button>
|
||||
</Link>
|
||||
<div className="w-4 h-px bg-white/10" />
|
||||
{/* Quick Snap */}
|
||||
{/* Layers popover — labelled toggles with color dots */}
|
||||
<Popover open={layersOpen} onOpenChange={setLayersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
onClick={() => setShowQuickCamera(true)}
|
||||
className="h-9 w-9 rounded-full bg-black/70 border border-white/15 flex items-center justify-center shadow-lg touch-manipulation active:scale-90 hover:border-[#ff0040]/50 hover:bg-[#ff0040]/10 transition-all"
|
||||
aria-label="Quick snap"
|
||||
className={cn(
|
||||
'relative h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
|
||||
layersOpen
|
||||
? 'bg-[#ff0040]/20 border-[#ff0040]/60'
|
||||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||||
)}
|
||||
aria-label="Map layers"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5 text-gray-300" />
|
||||
<Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
|
||||
{mobileLayerCount < 4 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[9px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none border-2 border-black">
|
||||
{mobileLayerCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="end"
|
||||
className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl"
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', label: 'Shops', dot: '#a855f7', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
].map(({ key, label, dot, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-1.5 py-2 rounded-lg text-sm font-semibold transition-colors touch-manipulation',
|
||||
active ? 'text-white hover:bg-white/5' : 'text-gray-500 hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('w-2.5 h-2.5 rounded-full flex-shrink-0 transition-opacity', active ? 'opacity-100' : 'opacity-30')}
|
||||
style={{ backgroundColor: dot }}
|
||||
/>
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors',
|
||||
active ? 'bg-[#ccff00] border-[#ccff00]' : 'border-white/20'
|
||||
)}
|
||||
>
|
||||
{active && <Check className="h-3 w-3 text-black" strokeWidth={3} />}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* ── Add speed-dial FAB (mobile, logged in) ──────────────────── */}
|
||||
{user && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed right-4 z-[1001] md:hidden flex flex-col items-end gap-2.5 transition-all duration-200',
|
||||
mobileFiltersOpen || selectedSpot ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||
)}
|
||||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1rem)' }}
|
||||
>
|
||||
{/* Fan-out actions */}
|
||||
{addDialOpen && (
|
||||
<>
|
||||
{[
|
||||
{ label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
|
||||
{ label: 'Add Shop', to: '/create-shop', bg: 'bg-[#a855f7]', icon: <Store className="h-4 w-4 text-white" /> },
|
||||
{ label: 'Offer Tour', to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
|
||||
].map((a, i) => (
|
||||
<Link
|
||||
key={a.to}
|
||||
to={a.to}
|
||||
onClick={() => setAddDialOpen(false)}
|
||||
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
|
||||
{a.label}
|
||||
</span>
|
||||
<span className={cn('h-11 w-11 rounded-full flex items-center justify-center shadow-lg', a.bg)}>
|
||||
{a.icon}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{/* Quick Snap */}
|
||||
<button
|
||||
onClick={() => { setAddDialOpen(false); setShowQuickCamera(true); }}
|
||||
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
style={{ animationDelay: '120ms' }}
|
||||
>
|
||||
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
|
||||
Quick Snap
|
||||
</span>
|
||||
<span className="h-11 w-11 rounded-full flex items-center justify-center shadow-lg bg-black/80 border border-white/20">
|
||||
<Camera className="h-4 w-4 text-[#ccff00]" />
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main FAB */}
|
||||
<button
|
||||
onClick={() => setAddDialOpen(v => !v)}
|
||||
className={cn(
|
||||
'h-14 w-14 rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 touch-manipulation active:scale-90',
|
||||
addDialOpen
|
||||
? 'bg-white rotate-45'
|
||||
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
|
||||
)}
|
||||
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, tour, or snap'}
|
||||
>
|
||||
<Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap-catcher to close the speed-dial */}
|
||||
{addDialOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] md:hidden"
|
||||
onClick={() => setAddDialOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
|
|
|
|||
Loading…
Reference in a new issue