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:
parent
e632285702
commit
daedf7ac57
3 changed files with 250 additions and 61 deletions
|
|
@ -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. */}
|
||||
<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
|
||||
ref={setMapDiv}
|
||||
className="h-full w-full bg-muted"
|
||||
className="h-full w-full bg-[#0b0b0c]"
|
||||
aria-label="Route map"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CrewFocus | 'all'>('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() {
|
|||
)}
|
||||
</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={cn('min-w-0 flex-1', !filtersExpanded && 'scroll-container overflow-x-auto')}>
|
||||
<ToggleGroup
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setFiltersExpanded((v) => !v)}
|
||||
aria-expanded={filtersExpanded}
|
||||
title={filtersExpanded ? 'Show fewer filters' : 'Show full filter names'}
|
||||
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"
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', filtersExpanded && 'rotate-180')} />
|
||||
{filtersExpanded ? 'Less' : 'More'}
|
||||
</button>
|
||||
<Sheet open={focusOpen} onOpenChange={setFocusOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-9 min-w-0 items-center gap-1.5 rounded-xl border px-3 text-sm font-bold transition-colors',
|
||||
focusFilter === 'all'
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
<FocusIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{currentFocus.label}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
</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. */}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { useMintDirectory, type MintDirectoryEntry } from '@/hooks/useMintDirectory';
|
||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isNativePlatform } from '@/lib/platform';
|
||||
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 {
|
||||
try {
|
||||
|
|
@ -40,16 +41,29 @@ function MintRow({
|
|||
canAdd,
|
||||
adding,
|
||||
onAdd,
|
||||
onSelect,
|
||||
}: {
|
||||
mint: MintDirectoryEntry;
|
||||
added: boolean;
|
||||
canAdd: boolean;
|
||||
adding: boolean;
|
||||
onAdd: () => void;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const health = healthBadge(mint);
|
||||
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">
|
||||
<div className="flex items-start gap-3">
|
||||
{mint.iconUrl ? (
|
||||
|
|
@ -100,7 +114,7 @@ function MintRow({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
{added ? (
|
||||
<Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">
|
||||
<Check className="h-3 w-3" /> Added
|
||||
|
|
@ -109,7 +123,10 @@ function MintRow({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAdd}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
disabled={adding}
|
||||
className="gap-1.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
|
|
@ -117,6 +134,7 @@ function MintRow({
|
|||
Add
|
||||
</Button>
|
||||
) : null}
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -131,6 +149,16 @@ export default function MintsPage() {
|
|||
const [query, setQuery] = useState('');
|
||||
const [onlineOnly, setOnlineOnly] = useState(false);
|
||||
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({
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -279,6 +308,145 @@ export default function MintsPage() {
|
|||
</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'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'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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue