diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index deddd55..2b88b8d 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -357,10 +357,17 @@ export function RouteDialog({ return () => document.removeEventListener('visibilitychange', onVisibility); }, [following, acquireWakeLock]); - // While following, keep the live dot centred. + // While following, keep the live dot centred — but only once you've actually + // moved, and with a gentle animation, so GPS jitter doesn't twitch the map. + const lastPanRef = useRef<{ lat: number; lng: number } | null>(null); useEffect(() => { if (!following || !live) return; - mapRef.current?.panTo([live.lat, live.lng]); + const map = mapRef.current; + if (!map) return; + const last = lastPanRef.current; + if (last && calculateDistance(last.lat, last.lng, live.lat, live.lng) * 1000 < 6) return; + lastPanRef.current = { lat: live.lat, lng: live.lng }; + map.panTo([live.lat, live.lng], { animate: true, duration: 0.6, easeLinearity: 0.5 }); }, [following, live]); /** Live guidance: distance left, and which step you're on. */ @@ -511,9 +518,16 @@ export function RouteDialog({ }; map.on('click', onMapClick); - setTimeout(() => map.invalidateSize(), 120); + // The dialog animates in (a brief scale), so the container is still + // settling at first paint. Nudge Leaflet's size once now and again after the + // animation ends — otherwise the map renders at the wrong size for a frame + // and visibly jumps (the "flash" people see when they tap Directions). + const t1 = setTimeout(() => map.invalidateSize(), 120); + const t2 = setTimeout(() => map.invalidateSize(), 340); return () => { + clearTimeout(t1); + clearTimeout(t2); map.off('click', onMapClick); map.remove(); mapRef.current = null; @@ -597,7 +611,7 @@ export function RouteDialog({ routeLayersRef.current = group; if (result.coordinates.length > 0) { - map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16 }); + map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false }); } } } catch (err) { @@ -639,7 +653,7 @@ export function RouteDialog({ lineCap: 'round', }).addTo(group); routeLayersRef.current = group; - map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16 }); + map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16, animate: false }); } } } @@ -677,10 +691,10 @@ export function RouteDialog({ `.leaflet-container { height: 100% }` override beats a `h-64` on the map element itself, and against a height-less parent that resolved to 0 — a fully invisible map with no route line. */} -
+
diff --git a/src/pages/CrewsPage.tsx b/src/pages/CrewsPage.tsx index 37280f1..d7f0727 100644 --- a/src/pages/CrewsPage.tsx +++ b/src/pages/CrewsPage.tsx @@ -4,20 +4,19 @@ import { Link } from 'react-router-dom'; import { LazyMount } from '@/components/LazyMount'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { useCrews } from '@/hooks/useCrews'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useGeolocation } from '@/hooks/useGeolocation'; import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers'; import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types'; import type { CrewActivity } from '@/lib/crewHelpers'; import { cn } from '@/lib/utils'; -import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film, ChevronDown } from 'lucide-react'; +import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film, ChevronDown, Check } from 'lucide-react'; // `label` is the full name (used for the tooltip + screen readers); `short` is // what the compact toggle shows, to keep the filter bar to a single row. @@ -158,9 +157,11 @@ export default function CrewsPage() { const geo = useGeolocation(); const [query, setQuery] = useState(''); const [focusFilter, setFocusFilter] = useState('all'); + const [focusOpen, setFocusOpen] = useState(false); const [nearMe, setNearMe] = useState(false); // Remembered preference: compact short labels vs. the full filter names. - const [filtersExpanded, setFiltersExpanded] = useLocalStorage('crews-filters-expanded', false); + const currentFocus = FOCUS_FILTERS.find((f) => f.key === focusFilter) ?? FOCUS_FILTERS[0]; + const FocusIcon = currentFocus.icon; const userLoc: LatLng | null = geo.coords; @@ -277,53 +278,59 @@ export default function CrewsPage() { )}
- {/* Filters: compact focus toggle group; "More" expands to full names */} + {/* Filters: ONE compact control instead of a horizontally-scrolling chip + row (on a phone the chips were clipped and had to be swiped, and the + expand toggle read as just another category). */}
-
- { - if (v) setFocusFilter(v as CrewFocus | 'all'); - }} - className={cn( - 'items-center justify-start gap-0.5 rounded-xl border border-white/10 bg-white/[0.04] p-1', - filtersExpanded ? 'flex flex-wrap' : 'inline-flex w-max', - )} - > - {FOCUS_FILTERS.map(({ key, label, short, icon: Icon }) => ( - - - {filtersExpanded ? label : short} - - ))} - -
- - {/* Expand/collapse the filter names (remembered across visits). - A divider + a labelled button keep it from reading as just another - crew category sitting next to the chips. */} - - + + + + + + + Filter crews + + What the crew is into. + + +
+ {FOCUS_FILTERS.map(({ key, label, icon: Icon }) => { + const active = focusFilter === key; + return ( + + ); + })} +
+
+
{/* Near me — mobile only; on desktop it was just clutter in this bar. */}
-
+
{added ? ( Added @@ -109,7 +123,10 @@ function MintRow({ ) : null} +
@@ -131,6 +149,16 @@ export default function MintsPage() { const [query, setQuery] = useState(''); const [onlineOnly, setOnlineOnly] = useState(false); const [addingUrl, setAddingUrl] = useState(null); + const [selected, setSelected] = useState(null); + + const copyUrl = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + toast({ title: 'Mint URL copied' }); + } catch { + toast({ title: 'Copy failed', variant: 'destructive' }); + } + }; useSeoMeta({ title: 'Cashu Mints — SkateSpots', @@ -265,6 +293,7 @@ export default function MintsPage() { canAdd={canAdd} adding={addingUrl === mint.url} onAdd={() => handleAdd(mint.url)} + onSelect={() => setSelected(mint)} /> ))}
@@ -279,6 +308,145 @@ export default function MintsPage() {

)} + + {/* Mint detail — everything the directory knows, plus what to watch for. */} + { + if (!o) setSelected(null); + }} + > + + {selected && ( + <> + +
+ {selected.iconUrl ? ( + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( +
+ +
+ )} +
+ + {selected.name || hostOf(selected.url)} + + + {hostOf(selected.url)} + +
+ + {healthBadge(selected).label} + +
+
+ + {selected.descriptionLong && ( +

{selected.descriptionLong}

+ )} + +
+ {[ + { + label: 'Latency', + value: + selected.online && typeof selected.latencyMs === 'number' + ? `${selected.latencyMs} ms` + : '—', + }, + { + label: 'NUTs supported', + value: typeof selected.nutCount === 'number' ? String(selected.nutCount) : '—', + }, + { label: 'Version', value: selected.version ?? '—' }, + { label: 'Last online', value: selected.lastOnlineAt ?? '—' }, + ].map((f) => ( +
+

+ {f.label} +

+

{f.value}

+
+ ))} +
+ +
+ {addedUrls.has(selected.url.toLowerCase()) ? ( + + In your wallet + + ) : canAdd ? ( + + ) : null} + + + + + {selected.tosUrl && ( + + + + )} +
+ +
+

+ What's a mint? +

+

+ A Cashu mint issues and redeems the ecash in your wallet. It holds the Bitcoin + backing your tokens — so a mint you don't run yourself is one you have to trust. +

+

+ + Mints can go offline, change terms, or disappear with funds. Keep only small + balances unless you trust the operator. +

+

+ Health data is community-run (MintRadar) — unofficial and possibly stale. +

+
+ + )} +
+
);