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:
parent
1ce2837fef
commit
682e4d802b
2 changed files with 199 additions and 5 deletions
|
|
@ -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 && (
|
||||
<>
|
||||
{' • '}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue