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);
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue