SkateSpots/src/components/CoordinatorSettingsCard.tsx
SkateSpots 52ed2f89bd feat(transit): real itineraries — alternatives, leg-by-leg, mode badges
Transit showed one cryptic summary line and threw away 3 of 4 itineraries.
Surface what the MOTIS/Transitous response already contains.

routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count,
realTime and distance; RouteResult gains transitOptions (all itineraries,
fastest first). fetchTransitRoute maps every itinerary (the API returns more
than numItineraries).

RouteDialog:
- Alternatives row — tap to switch; a dedicated effect redraws the map from the
  selected itinerary, so switching never re-queries the service.
- Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock
  times, platform track, N stops, operator, and a 'live' badge only when the
  feed is realtime.
- Summary now reflects the selected option and shows the option count.
- 'Whole route' re-fit follows the selection.

Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the
documented zero-warning baseline holds.

Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
2026-10-01 10:58:27 +07:00

287 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
AlertTriangle,
Copy,
ExternalLink,
QrCode,
RefreshCw,
ScanLine,
Server,
Smartphone,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
import { toast } from '@/hooks/useToast';
import {
CORDN_APP_URL,
CORDN_DOCKER_COMMAND,
CORDN_RELAY_URL,
coordinatorAddUrl,
parseCoordinatorInput,
shortNpub,
} from '@/lib/coordinator';
import { cn } from '@/lib/utils';
function timeAgo(unixSeconds: number): string {
const s = Math.floor(Date.now() / 1000 - unixSeconds);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
const STATUS_META: Record<
CoordinatorHealthStatus,
{ label: string; color: string; ring: string; dot: string }
> = {
idle: { label: 'Not checked', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
checking: { label: 'Checking…', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
online: { label: 'Connected', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
stale: { label: 'Quiet', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
offline: { label: 'Not seen', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
error: { label: 'Unreachable', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
};
async function copyText(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
toast({ title: `${label} copied` });
} catch {
toast({ title: 'Copy failed', description: 'Copy it manually.', variant: 'destructive' });
}
}
/**
* Settings → Coordinator.
*
* Lets a user point SkateSpots at a Cordn coordinator they've chosen or run
* themselves — and, crucially, makes "run one on a phone" a hand-off to Cordn
* rather than something we embed. The connected state is an honest presence
* probe: a ContextVM server is visible only if it announces.
*/
export function CoordinatorSettingsCard() {
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator);
const [input, setInput] = useState('');
const [showQr, setShowQr] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
// Probe once when a coordinator is set/changed, so the state is "connected"
// without the user pressing anything.
useEffect(() => {
if (coordinator) void check();
}, [coordinator, check]);
const handleAdd = (raw: string) => {
const parsed = parseCoordinatorInput(raw);
if (!parsed) {
toast({
variant: 'destructive',
title: "That's not a coordinator",
description: 'Paste an nprofile, an npub, or a cordn.net link.',
});
return;
}
setCoordinator({
pubkey: parsed.pubkey,
relays: parsed.relays.length ? parsed.relays : [CORDN_RELAY_URL],
});
setInput('');
toast({ title: 'Coordinator added', description: shortNpub(parsed.pubkey) });
};
const meta = STATUS_META[status];
return (
<div className="space-y-4">
{/* ── Current coordinator, or the empty state ─────────────────────── */}
{coordinator ? (
<div className={cn('rounded-2xl border overflow-hidden bg-white/2', meta.ring)}>
<div className="px-4 py-3.5 border-b border-white/5 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white flex items-center gap-2">
<Server className="h-4 w-4 text-cyan-400" />
<span className="truncate">{name ?? shortNpub(coordinator.pubkey)}</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{coordinator.relays.length} relay{coordinator.relays.length === 1 ? '' : 's'}
{lastSeen ? ` · last seen ${timeAgo(lastSeen)}` : ''}
</p>
</div>
<span
className={cn(
'shrink-0 flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full border',
meta.ring,
meta.color,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
</span>
</div>
<div className="px-4 py-3.5 space-y-3">
<p className="text-xs leading-relaxed text-muted-foreground">
{status === 'online' && 'Announcing. Groups that use it can deliver messages now.'}
{status === 'stale' && 'Found, but it has not announced recently — it may be asleep.'}
{status === 'offline' &&
'No announcement seen. It may be private (that is fine), or simply offline.'}
{status === 'error' && (error ?? 'Could not reach the relays.')}
{status === 'idle' && 'Checking…'}
{status === 'checking' && 'Looking for its announcement…'}
{eventCount > 0 && ` · ${eventCount} event${eventCount === 1 ? '' : 's'} found.`}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
Check
</Button>
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), 'Add link')}>
<Copy className="mr-1.5 h-3.5 w-3.5" />
Add link
</Button>
<Button size="sm" variant="outline" onClick={() => setShowQr((v) => !v)}>
<QrCode className="mr-1.5 h-3.5 w-3.5" />
QR
</Button>
<Button
size="sm"
variant="ghost"
className="ml-auto text-muted-foreground hover:text-red-400"
onClick={() => {
if (window.confirm('Remove this coordinator from this device?')) removeCoordinator();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{showQr && (
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/30 border border-white/5 p-4">
<QRCodeCanvas value={coordinatorAddUrl(coordinator)} size={180} level="M" />
<p className="text-[11px] text-muted-foreground text-center">
Scan in Cordn to join this coordinator.
</p>
</div>
)}
<p className="text-[11px] text-muted-foreground/70 font-mono truncate">
{shortNpub(coordinator.pubkey)}
</p>
</div>
</div>
) : (
<div className="rounded-2xl border border-cyan-500/25 bg-cyan-500/[0.06] p-4 space-y-3">
<p className="text-sm font-bold text-white flex items-center gap-2">
<Server className="h-4 w-4 text-cyan-400" />
No coordinator yet
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
A coordinator delivers your crew&apos;s <strong className="text-white">encrypted</strong> group
messages in order. It sees <em>who, when and which group</em> — never the contents. Pick one,
or run your own.
</p>
</div>
)}
{/* ── Add / change ────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
<p className="text-sm font-bold text-white">{coordinator ? 'Change coordinator' : 'Add a coordinator'}</p>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="nprofile… or a cordn.net link"
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter' && input.trim()) handleAdd(input);
}}
/>
<Button size="sm" onClick={() => handleAdd(input)} disabled={!input.trim()} className="shrink-0">
Add
</Button>
</div>
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
Scan a coordinator QR
</Button>
</div>
{/* ── Run your own (the hand-off) ─────────────────────────────────── */}
<div className="rounded-2xl border border-white/8 bg-white/2 overflow-hidden">
<div className="px-4 py-3.5 border-b border-white/5">
<p className="text-sm font-bold text-white">Run your own</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
It can live on an old phone, a Raspberry Pi, or any small box you leave on. Your group keeps
working while it&apos;s up — and you can move the group whenever you like.
</p>
</div>
<div className="px-4 py-3.5 space-y-3 border-b border-white/5">
<p className="flex items-center gap-2 text-xs font-bold text-white">
<Smartphone className="h-3.5 w-3.5 text-cyan-400" />
On a phone
</p>
<p className="text-xs text-muted-foreground">
Use the Cordn Coordinator app, then scan its QR here to add it.
</p>
<div className="flex flex-wrap gap-2">
<a href={CORDN_APP_URL} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
Open Cordn
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Button>
</a>
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
Scan its QR
</Button>
</div>
</div>
<div className="px-4 py-3.5 space-y-3">
<p className="flex items-center gap-2 text-xs font-bold text-white">
<Server className="h-3.5 w-3.5 text-cyan-400" />
On a server
</p>
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
{CORDN_DOCKER_COMMAND}
</code>
<button
type="button"
onClick={() => void copyText(CORDN_DOCKER_COMMAND, 'Command')}
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
aria-label="Copy command"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
Android may sleep a phone coordinator. Expect to move a group now and then.
</p>
</div>
</div>
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
🔒 A coordinator sees ciphertext and metadata only — never message contents. Run your own to keep
even the metadata yours.
</p>
<QRScannerModal
isOpen={scanOpen}
onClose={() => setScanOpen(false)}
onResult={(result) => handleAdd(result.value)}
/>
</div>
);
}