ui: mint details sheet, crews type filter, smoother directions

- Mints: the whole row is now clickable and opens a detail sheet with every
  field the directory has (status, latency, NUTs, version, last online,
  description), Copy URL / Open / Terms actions, an Add button, and an honest
  'what is a mint + mints can rug' explainer.
- Crews: replaced the horizontally-scrolling category chips + More/Less toggle
  with one compact type control that opens a bottom sheet listing every focus
  (single-select). No more swiping the chips on a phone, and nothing that reads
  as a category when it isn't.
- Directions: the flash was three things - the dialog's scale-in animation while
  Leaflet measured itself, a bg-muted map box showing before tiles load, and an
  animated fitBounds jump when the route arrived. Fixed with a second
  invalidateSize after the animation, a dark map background, and
  animate:false on the route fit. Following is smoother too: re-centre only
  after ~6m of movement, with a gentle 0.6s pan, so GPS jitter doesn't twitch
  the map.
This commit is contained in:
SkateSpots 2026-09-29 17:47:58 +07:00
parent e632285702
commit daedf7ac57
3 changed files with 250 additions and 61 deletions

View file

@ -357,10 +357,17 @@ export function RouteDialog({
return () => document.removeEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility);
}, [following, acquireWakeLock]); }, [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(() => { useEffect(() => {
if (!following || !live) return; 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]); }, [following, live]);
/** Live guidance: distance left, and which step you're on. */ /** Live guidance: distance left, and which step you're on. */
@ -511,9 +518,16 @@ export function RouteDialog({
}; };
map.on('click', onMapClick); 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 () => { return () => {
clearTimeout(t1);
clearTimeout(t2);
map.off('click', onMapClick); map.off('click', onMapClick);
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
@ -597,7 +611,7 @@ export function RouteDialog({
routeLayersRef.current = group; routeLayersRef.current = group;
if (result.coordinates.length > 0) { 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) { } catch (err) {
@ -639,7 +653,7 @@ export function RouteDialog({
lineCap: 'round', lineCap: 'round',
}).addTo(group); }).addTo(group);
routeLayersRef.current = 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 `.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to map element itself, and against a height-less parent that resolved to
0 — a fully invisible map with no route line. */} 0 — a fully invisible map with no route line. */}
<div className={cn('order-2 relative shrink-0', following ? 'h-[60vh] min-h-[360px] sm:h-[62vh]' : 'h-[50vh] min-h-[300px] sm:h-[440px]')}> <div className={cn('order-2 relative shrink-0 bg-[#0b0b0c]', following ? 'h-[60vh] min-h-[360px] sm:h-[62vh]' : 'h-[50vh] min-h-[300px] sm:h-[440px]')}>
<div <div
ref={setMapDiv} ref={setMapDiv}
className="h-full w-full bg-muted" className="h-full w-full bg-[#0b0b0c]"
aria-label="Route map" aria-label="Route map"
/> />

View file

@ -4,20 +4,19 @@ import { Link } from 'react-router-dom';
import { LazyMount } from '@/components/LazyMount'; import { LazyMount } from '@/components/LazyMount';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; 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 { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge'; import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
import { useCrews } from '@/hooks/useCrews'; import { useCrews } from '@/hooks/useCrews';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useGeolocation } from '@/hooks/useGeolocation'; import { useGeolocation } from '@/hooks/useGeolocation';
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers'; import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types'; import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
import type { CrewActivity } from '@/lib/crewHelpers'; import type { CrewActivity } from '@/lib/crewHelpers';
import { cn } from '@/lib/utils'; 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 // `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. // 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 geo = useGeolocation();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all'); const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
const [focusOpen, setFocusOpen] = useState(false);
const [nearMe, setNearMe] = useState(false); const [nearMe, setNearMe] = useState(false);
// Remembered preference: compact short labels vs. the full filter names. // 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; const userLoc: LatLng | null = geo.coords;
@ -277,53 +278,59 @@ export default function CrewsPage() {
)} )}
</div> </div>
{/* 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). */}
<div className="mb-5 flex items-center gap-2"> <div className="mb-5 flex items-center gap-2">
<div className={cn('min-w-0 flex-1', !filtersExpanded && 'scroll-container overflow-x-auto')}> <Sheet open={focusOpen} onOpenChange={setFocusOpen}>
<ToggleGroup <SheetTrigger asChild>
type="single"
value={focusFilter}
onValueChange={(v) => {
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 }) => (
<ToggleGroupItem
key={key}
value={key}
title={label}
aria-label={label}
className={cn(
'h-7 shrink-0 gap-1 rounded-lg px-2 text-xs font-bold text-gray-300 transition-colors',
'hover:bg-white/5 hover:text-teal-300',
'data-[state=on]:bg-teal-500 data-[state=on]:text-black data-[state=on]:hover:bg-teal-500 data-[state=on]:hover:text-black',
)}
>
<Icon className="h-3.5 w-3.5" />
{filtersExpanded ? label : short}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{/* 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. */}
<span aria-hidden className="h-6 w-px shrink-0 bg-white/10" />
<button <button
type="button" type="button"
onClick={() => setFiltersExpanded((v) => !v)} className={cn(
aria-expanded={filtersExpanded} 'inline-flex h-9 min-w-0 items-center gap-1.5 rounded-xl border px-3 text-sm font-bold transition-colors',
title={filtersExpanded ? 'Show fewer filters' : 'Show full filter names'} focusFilter === 'all'
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs font-bold text-gray-400 transition-colors hover:bg-white/5 hover:text-teal-300" ? 'border-white/10 bg-white/[0.04] text-gray-300 hover:text-teal-300'
: 'border-teal-500/50 bg-teal-500/15 text-teal-300',
)}
> >
<ChevronDown className={cn('h-4 w-4 transition-transform', filtersExpanded && 'rotate-180')} /> <FocusIcon className="h-4 w-4 shrink-0" />
{filtersExpanded ? 'Less' : 'More'} <span className="truncate">{currentFocus.label}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
</button> </button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-2xl px-4 pb-8">
<SheetHeader className="pb-3 text-left">
<SheetTitle className="text-base">Filter crews</SheetTitle>
<SheetDescription className="text-xs">
What the crew is into.
</SheetDescription>
</SheetHeader>
<div className="space-y-1">
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
const active = focusFilter === key;
return (
<button
key={key}
type="button"
onClick={() => {
setFocusFilter(key);
setFocusOpen(false);
}}
aria-pressed={active}
className={cn(
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-bold transition-colors',
active ? 'bg-teal-500 text-black' : 'text-gray-200 hover:bg-white/5',
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{active && <Check className="h-4 w-4 shrink-0" />}
</button>
);
})}
</div>
</SheetContent>
</Sheet>
{/* Near me — mobile only; on desktop it was just clutter in this bar. */} {/* Near me — mobile only; on desktop it was just clutter in this bar. */}
<button <button

View file

@ -6,12 +6,13 @@ import { Badge } from '@/components/ui/badge';
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 { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useMintDirectory, type MintDirectoryEntry } from '@/hooks/useMintDirectory'; import { useMintDirectory, type MintDirectoryEntry } from '@/hooks/useMintDirectory';
import { useCashu } from '@/hooks/wallet/useCashu'; import { useCashu } from '@/hooks/wallet/useCashu';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { isNativePlatform } from '@/lib/platform'; import { isNativePlatform } from '@/lib/platform';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Search, Coins, ExternalLink, Plus, Loader2, Check, ShieldAlert, Activity } from 'lucide-react'; import { Search, Coins, ExternalLink, Plus, Loader2, Check, ShieldAlert, Activity, ChevronRight, Copy, Info } from 'lucide-react';
function hostOf(url: string): string { function hostOf(url: string): string {
try { try {
@ -40,16 +41,29 @@ function MintRow({
canAdd, canAdd,
adding, adding,
onAdd, onAdd,
onSelect,
}: { }: {
mint: MintDirectoryEntry; mint: MintDirectoryEntry;
added: boolean; added: boolean;
canAdd: boolean; canAdd: boolean;
adding: boolean; adding: boolean;
onAdd: () => void; onAdd: () => void;
onSelect: () => void;
}) { }) {
const health = healthBadge(mint); const health = healthBadge(mint);
return ( return (
<Card className="bg-black/90 border-2 border-white/10 hover:border-[#ccff00]/30 transition-colors"> <Card
className="cursor-pointer bg-black/90 border-2 border-white/10 hover:border-[#ccff00]/30 transition-colors"
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{mint.iconUrl ? ( {mint.iconUrl ? (
@ -100,7 +114,7 @@ function MintRow({
</div> </div>
</div> </div>
<div className="shrink-0"> <div className="shrink-0 flex items-center gap-1.5">
{added ? ( {added ? (
<Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30"> <Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">
<Check className="h-3 w-3" /> Added <Check className="h-3 w-3" /> Added
@ -109,7 +123,10 @@ function MintRow({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={onAdd} onClick={(e) => {
e.stopPropagation();
onAdd();
}}
disabled={adding} disabled={adding}
className="gap-1.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10" className="gap-1.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
> >
@ -117,6 +134,7 @@ function MintRow({
Add Add
</Button> </Button>
) : null} ) : null}
<ChevronRight className="h-4 w-4 shrink-0 text-gray-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -131,6 +149,16 @@ export default function MintsPage() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [onlineOnly, setOnlineOnly] = useState(false); const [onlineOnly, setOnlineOnly] = useState(false);
const [addingUrl, setAddingUrl] = useState<string | null>(null); const [addingUrl, setAddingUrl] = useState<string | null>(null);
const [selected, setSelected] = useState<MintDirectoryEntry | null>(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({ useSeoMeta({
title: 'Cashu Mints — SkateSpots', title: 'Cashu Mints — SkateSpots',
@ -265,6 +293,7 @@ export default function MintsPage() {
canAdd={canAdd} canAdd={canAdd}
adding={addingUrl === mint.url} adding={addingUrl === mint.url}
onAdd={() => handleAdd(mint.url)} onAdd={() => handleAdd(mint.url)}
onSelect={() => setSelected(mint)}
/> />
))} ))}
</div> </div>
@ -279,6 +308,145 @@ export default function MintsPage() {
</p> </p>
</> </>
)} )}
{/* Mint detail — everything the directory knows, plus what to watch for. */}
<Sheet
open={!!selected}
onOpenChange={(o) => {
if (!o) setSelected(null);
}}
>
<SheetContent side="bottom" className="max-h-[88vh] overflow-y-auto rounded-t-2xl px-4 pb-8">
{selected && (
<>
<SheetHeader className="pb-3 text-left">
<div className="flex items-center gap-3">
{selected.iconUrl ? (
<img
src={selected.iconUrl}
alt=""
className="h-12 w-12 rounded-xl border border-white/10 bg-white/5 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/5">
<Coins className="h-6 w-6 text-[#ccff00]/60" />
</div>
)}
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-lg">
{selected.name || hostOf(selected.url)}
</SheetTitle>
<SheetDescription className="truncate text-xs">
{hostOf(selected.url)}
</SheetDescription>
</div>
<Badge
variant="outline"
className={cn('shrink-0 text-[10px]', healthBadge(selected).className)}
>
{healthBadge(selected).label}
</Badge>
</div>
</SheetHeader>
{selected.descriptionLong && (
<p className="text-sm leading-relaxed text-gray-300">{selected.descriptionLong}</p>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
{[
{
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) => (
<div
key={f.label}
className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2"
>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{f.label}
</p>
<p className="truncate text-sm font-bold text-white">{f.value}</p>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{addedUrls.has(selected.url.toLowerCase()) ? (
<Badge className="gap-1 border border-[#ccff00]/30 bg-[#ccff00]/15 text-[#ccff00]">
<Check className="h-3 w-3" /> In your wallet
</Badge>
) : canAdd ? (
<Button
size="sm"
onClick={() => handleAdd(selected.url)}
disabled={addingUrl === selected.url}
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{addingUrl === selected.url ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Add to wallet
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/15"
onClick={() => void copyUrl(selected.url)}
>
<Copy className="h-3.5 w-3.5" /> Copy URL
</Button>
<a href={selected.url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline" className="gap-1.5 border-white/15">
<ExternalLink className="h-3.5 w-3.5" /> Open mint
</Button>
</a>
{selected.tosUrl && (
<a href={selected.tosUrl} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline" className="gap-1.5 border-white/15">
<ExternalLink className="h-3.5 w-3.5" /> Terms
</Button>
</a>
)}
</div>
<div className="mt-4 space-y-2 rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<p className="flex items-center gap-2 text-xs font-bold text-white">
<Info className="h-4 w-4 text-[#ccff00]" /> What&apos;s a mint?
</p>
<p className="text-xs leading-relaxed text-gray-400">
A Cashu mint issues and redeems the ecash in your wallet. It holds the Bitcoin
backing your tokens — so a mint you don&apos;t run yourself is one you have to trust.
</p>
<p className="flex items-start gap-2 text-xs leading-relaxed text-amber-400/90">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Mints can go offline, change terms, or disappear with funds. Keep only small
balances unless you trust the operator.
</p>
<p className="text-[11px] text-gray-600">
Health data is community-run (MintRadar) — unofficial and possibly stale.
</p>
</div>
</>
)}
</SheetContent>
</Sheet>
</div> </div>
</> </>
); );