map: dark tiles, near me button, bottom sheet, floating filter pills

- Swap OSM tiles for CartoDB Dark Matter (near-black bg, neon markers pop)
- Add floating Near Me button (top-right, crosshair icon, flies to user location)
- Add SpotBottomSheet: tap a marker → drawer slides up from bottom with
  spot thumbnail, title, location, type badges, author, View Details + Directions CTAs
  (map stays fully visible behind the sheet)
- Add flyTo prop to SpotMap for programmatic map navigation
- Add floating layer filter pills (spots/shops/gear/tours) on map surface
  bottom-left, always visible, no toolbar required

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 19:57:06 +07:00
parent 1ce2837fef
commit 682e4d802b
2 changed files with 199 additions and 5 deletions

View file

@ -321,6 +321,7 @@ interface SpotMapProps {
showGear?: boolean;
showTours?: boolean;
showBtcMap?: boolean;
flyTo?: { lat: number; lng: number; zoom?: number } | null;
}
function createPopupContent(
@ -562,6 +563,7 @@ export function SpotMap({
showGear = true,
showTours = true,
showBtcMap = false,
flyTo = null,
}: SpotMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
@ -670,9 +672,9 @@ export function SpotMap({
maxBoundsViscosity: 1.0,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '',
// Match minZoom so tiles are always available at the minimum zoom level
subdomains: 'abcd',
minZoom: 2,
}).addTo(map);
@ -777,6 +779,13 @@ export function SpotMap({
}
}, [selectedSpot]);
// Fly to location when flyTo prop changes
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !flyTo) return;
map.flyTo([flyTo.lat, flyTo.lng], flyTo.zoom ?? 14, { animate: true, duration: 1.2 });
}, [flyTo]);
// Update markers
useEffect(() => {
const map = mapInstanceRef.current;
@ -1133,15 +1142,23 @@ export function SpotMap({
{/* OpenStreetMap Attribution */}
<div className="absolute bottom-2 left-2 z-[1000] bg-black/80 backdrop-blur px-2 py-1 rounded text-[10px] text-gray-400">
Map data ©{' '}
<a
href="https://carto.com/attributions"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
CARTO
</a>
{' '}·{' '}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
OpenStreetMap
OSM
</a>
{' '}contributors
{showBtcMap && (
<>
{' • '}

View file

@ -12,7 +12,7 @@ import { SortDropdown, type SortOption, calculateDistance } from '@/components/S
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from '@/components/ui/sheet';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
@ -62,6 +62,8 @@ import {
SlidersHorizontal,
Check,
Camera,
Navigation,
ExternalLink,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -323,6 +325,111 @@ function StashDrawer() {
);
}
// Bottom sheet shown when a map marker is tapped
function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => void }) {
const navigate = useNavigate();
const author = useAuthor(spot.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(spot.pubkey);
const isShop = spot.types.includes('shop');
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
return (
<div className="flex flex-col">
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-white/20" />
</div>
<div className="px-4 pb-2">
{/* Header row */}
<div className="flex items-start gap-3 mb-3">
{/* Thumbnail */}
<div className="w-16 h-16 rounded-xl overflow-hidden bg-white/5 flex-shrink-0 border border-white/10">
{spot.images[0] ? (
<img src={spot.images[0]} alt={spot.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
{isShop ? '🏪' : '🛹'}
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h2 className="font-black text-white text-lg leading-tight truncate">{spot.title}</h2>
</div>
{spot.location && (
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0" />
{spot.location}
</p>
)}
{/* Type badges */}
<div className="flex flex-wrap gap-1 mt-1.5">
{spot.types.slice(0, 3).map((type) => (
<span
key={type}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-white/10 text-white/70"
>
{SPOT_TYPE_LABELS[type] || type}
</span>
))}
</div>
</div>
{/* Close */}
<button
onClick={onClose}
className="p-1.5 rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors flex-shrink-0 -mt-0.5"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Description */}
{spot.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{spot.description}</p>
)}
{/* Author */}
<div className="flex items-center gap-2 mb-4">
<Avatar className="h-5 w-5">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="text-[8px] bg-white/10">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground">Added by <span className="text-white/70">{displayName}</span></span>
</div>
{/* CTA buttons */}
<div className="grid grid-cols-2 gap-2 pb-safe pb-4">
<Button
onClick={() => navigate(route)}
className="h-12 font-bold text-sm bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 rounded-xl"
>
<ExternalLink className="h-4 w-4 mr-2" />
View Details
</Button>
{spot.geohash && (() => {
const { latitude, longitude } = ngeohash.decode(spot.geohash);
return (
<Button
variant="outline"
className="h-12 font-bold text-sm border-white/20 hover:bg-white/10 rounded-xl"
onClick={() => window.open(`https://maps.google.com/?q=${latitude},${longitude}`, '_blank')}
>
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
Directions
</Button>
);
})()}
</div>
</div>
</div>
);
}
export default function MapPage() {
const { t } = useTranslation();
@ -377,6 +484,24 @@ export default function MapPage() {
// BTC Map center for fetching places
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 });
// Near Me fly-to state
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [nearMeLoading, setNearMeLoading] = useState(false);
const handleNearMe = () => {
if (!navigator.geolocation) return;
setNearMeLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 });
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
setNearMeLoading(false);
},
() => setNearMeLoading(false),
{ enableHighAccuracy: true, timeout: 8000 }
);
};
const categoryKeys = Object.keys(SPOT_CATEGORIES);
@ -1111,6 +1236,7 @@ export default function MapPage() {
showGear={showGear}
showTours={showTours}
showBtcMap={showBtcMap}
flyTo={flyTo}
/>
</Suspense>
{/* Loading indicator */}
@ -1123,6 +1249,47 @@ export default function MapPage() {
</div>
)}
{/* Floating layer filter pills — bottom-left above attribution */}
<div className="absolute bottom-8 left-3 z-[1000] flex gap-1.5 flex-wrap max-w-[calc(100%-1.5rem)]">
{[
{ key: 'spots', label: '🛹', active: showSpots, toggle: () => setShowSpots(v => !v), color: 'bg-red-500' },
{ key: 'shops', label: '🏪', active: showShops, toggle: () => setShowShops(v => !v), color: 'bg-purple-600' },
{ key: 'gear', label: '🛒', active: showGear, toggle: () => setShowGear(v => !v), color: 'bg-orange-500' },
{ key: 'tours', label: '🗺️', active: showTours, toggle: () => setShowTours(v => !v), color: 'bg-cyan-500' },
].map(({ key, label, active, toggle, color }) => (
<button
key={key}
onClick={toggle}
className={cn(
'h-8 w-8 rounded-full text-sm flex items-center justify-center shadow-lg border transition-all backdrop-blur',
active
? `${color} border-white/30 shadow-black/40`
: 'bg-black/70 border-white/10 opacity-50'
)}
title={key}
>
{label}
</button>
))}
</div>
{/* Near Me button — floating top-right */}
<div className="absolute top-4 right-4 z-[1000]">
<Button
onClick={handleNearMe}
disabled={nearMeLoading}
size="sm"
className="h-10 w-10 rounded-full bg-black/80 hover:bg-black border border-white/20 shadow-lg backdrop-blur p-0"
title="Near me"
>
{nearMeLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
) : (
<Target className="h-4 w-4 text-[#ccff00]" />
)}
</Button>
</div>
{/* Quick Capture FAB - only show for logged in users on mobile */}
{user && (
<div className="absolute bottom-24 right-4 z-[1000] md:hidden">
@ -1198,6 +1365,16 @@ export default function MapPage() {
onCapture={handleQuickCameraCapture}
isUploading={isUploadingQuickPhoto}
/>
{/* Spot Bottom Sheet — slides up when a marker is tapped, map stays visible */}
<Sheet open={!!selectedSpot} onOpenChange={(open) => { if (!open) setSelectedSpot(null); }}>
<SheetContent
side="bottom"
className="h-auto max-h-[55vh] rounded-t-2xl px-0 pb-0 bg-black/95 border-t border-white/10"
>
{selectedSpot && <SpotBottomSheet spot={selectedSpot} onClose={() => setSelectedSpot(null)} />}
</SheetContent>
</Sheet>
</Layout>
);
}