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;
|
showGear?: boolean;
|
||||||
showTours?: boolean;
|
showTours?: boolean;
|
||||||
showBtcMap?: boolean;
|
showBtcMap?: boolean;
|
||||||
|
flyTo?: { lat: number; lng: number; zoom?: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPopupContent(
|
function createPopupContent(
|
||||||
|
|
@ -562,6 +563,7 @@ export function SpotMap({
|
||||||
showGear = true,
|
showGear = true,
|
||||||
showTours = true,
|
showTours = true,
|
||||||
showBtcMap = false,
|
showBtcMap = false,
|
||||||
|
flyTo = null,
|
||||||
}: SpotMapProps) {
|
}: SpotMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
|
|
@ -670,9 +672,9 @@ export function SpotMap({
|
||||||
maxBoundsViscosity: 1.0,
|
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: '',
|
attribution: '',
|
||||||
// Match minZoom so tiles are always available at the minimum zoom level
|
subdomains: 'abcd',
|
||||||
minZoom: 2,
|
minZoom: 2,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
@ -777,6 +779,13 @@ export function SpotMap({
|
||||||
}
|
}
|
||||||
}, [selectedSpot]);
|
}, [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
|
// Update markers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstanceRef.current;
|
const map = mapInstanceRef.current;
|
||||||
|
|
@ -1133,15 +1142,23 @@ export function SpotMap({
|
||||||
{/* OpenStreetMap Attribution */}
|
{/* 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">
|
<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 ©{' '}
|
Map data ©{' '}
|
||||||
|
<a
|
||||||
|
href="https://carto.com/attributions"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#ccff00] hover:underline"
|
||||||
|
>
|
||||||
|
CARTO
|
||||||
|
</a>
|
||||||
|
{' '}·{' '}
|
||||||
<a
|
<a
|
||||||
href="https://www.openstreetmap.org/copyright"
|
href="https://www.openstreetmap.org/copyright"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#ccff00] hover:underline"
|
className="text-[#ccff00] hover:underline"
|
||||||
>
|
>
|
||||||
OpenStreetMap
|
OSM
|
||||||
</a>
|
</a>
|
||||||
{' '}contributors
|
|
||||||
{showBtcMap && (
|
{showBtcMap && (
|
||||||
<>
|
<>
|
||||||
{' • '}
|
{' • '}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { SortDropdown, type SortOption, calculateDistance } from '@/components/S
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
@ -62,6 +62,8 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Check,
|
Check,
|
||||||
Camera,
|
Camera,
|
||||||
|
Navigation,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CameraCapture } from '@/components/CameraCapture';
|
import { CameraCapture } from '@/components/CameraCapture';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
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() {
|
export default function MapPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -378,6 +485,24 @@ export default function MapPage() {
|
||||||
// BTC Map center for fetching places
|
// BTC Map center for fetching places
|
||||||
const [mapCenter, setMapCenter] = useState<{ lat: number; lon: number }>({ lat: 40.7128, lon: -74.006 });
|
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);
|
const categoryKeys = Object.keys(SPOT_CATEGORIES);
|
||||||
|
|
||||||
// Fetch BTC Map places when enabled
|
// Fetch BTC Map places when enabled
|
||||||
|
|
@ -1111,6 +1236,7 @@ export default function MapPage() {
|
||||||
showGear={showGear}
|
showGear={showGear}
|
||||||
showTours={showTours}
|
showTours={showTours}
|
||||||
showBtcMap={showBtcMap}
|
showBtcMap={showBtcMap}
|
||||||
|
flyTo={flyTo}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
|
|
@ -1123,6 +1249,47 @@ export default function MapPage() {
|
||||||
</div>
|
</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 */}
|
{/* Quick Capture FAB - only show for logged in users on mobile */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="absolute bottom-24 right-4 z-[1000] md:hidden">
|
<div className="absolute bottom-24 right-4 z-[1000] md:hidden">
|
||||||
|
|
@ -1198,6 +1365,16 @@ export default function MapPage() {
|
||||||
onCapture={handleQuickCameraCapture}
|
onCapture={handleQuickCameraCapture}
|
||||||
isUploading={isUploadingQuickPhoto}
|
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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue