Files
routstr-core/ui/components/landing/cheat-sheet.tsx
T

468 lines
17 KiB
TypeScript

'use client';
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfigurationService } from '@/lib/api/services/configuration';
import {
CashuPaymentWorkflow,
type RefundReceipt,
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
type NodeInfo = {
name?: string;
description?: string;
version?: string;
http_url?: string;
onion_url?: string;
npub?: string;
mints?: string[];
child_key_cost_msats?: number;
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
const DEFAULT_BASE_URL = 'http://127.0.0.1:8000';
async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
const response = await fetch(`${baseUrl}/v1/info`, {
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load node info');
}
const payload = (await response.json()) as NodeInfo;
return {
...payload,
mints: Array.isArray(payload.mints) ? payload.mints : [],
};
}
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/\/+$/, '');
}
export function CheatSheet(): JSX.Element {
const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
);
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(
null
);
useEffect(() => {
if (!baseUrl && typeof window !== 'undefined') {
setBaseUrl(ConfigurationService.getLocalBaseUrl());
}
}, [baseUrl]);
const normalizedBaseUrl = useMemo(
() => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
[baseUrl]
);
const {
data: nodeInfo,
isLoading: isInfoLoading,
isError: isInfoError,
refetch: refetchNodeInfo,
} = useQuery({
queryKey: ['node-info', normalizedBaseUrl],
queryFn: () => fetchNodeInfo(normalizedBaseUrl),
enabled: Boolean(normalizedBaseUrl),
refetchInterval: 300_000,
staleTime: 120_000,
});
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleApiKeyCreated = useCallback(
(apiKey: string, snapshot: WalletSnapshot) => {
setApiKeyInput(apiKey);
setWalletInfo(snapshot);
setRefundReceipt(null);
},
[]
);
const handleApiKeyChanged = useCallback((apiKey: string) => {
setApiKeyInput(apiKey);
}, []);
const handleWalletInfoUpdated = useCallback((info: WalletSnapshot | null) => {
setWalletInfo(info);
}, []);
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
setRefundReceipt(receipt);
// setWalletInfo(null); // Keep info
// setApiKeyInput(''); // Keep input
}, []);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
const result = await refetchNodeInfo();
if (result.error) {
toast.error('Unable to refresh node info');
} else {
toast.success('Node info refreshed');
}
}, [refetchNodeInfo]);
const curlSnippet = useMemo(() => {
const keyPreview = apiKeyInput || 'YOUR_API_KEY';
return [
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
` -H "Authorization: Bearer ${keyPreview}"`,
' -H "Content-Type: application/json"',
" -d '{",
' "model": "openai/gpt-4o-mini",',
' "messages": [',
' {"role":"system","content":"You are Routstr."},',
' {"role":"user","content":"Ping the node"}',
' ]',
" }'",
].join('\n');
}, [apiKeyInput, normalizedBaseUrl]);
const refundToken = refundReceipt?.token ?? null;
return (
<div className='bg-background min-h-screen'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute top-0 right-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] tracking-wider'>
<Bolt className='text-primary h-4 w-4' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
Node Identity and Cheat Sheet
</h1>
<div className='md:hidden'>
<Button asChild size='sm' className='w-full sm:w-auto'>
<a href='/login'>Admin</a>
</Button>
</div>
</section>
<section className='grid gap-4 lg:grid-cols-2'>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-lg'>Node identity</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
/v1/info snapshot
</p>
</div>
<Button
variant='ghost'
size='sm'
className='gap-1 text-xs'
onClick={handleRefreshInfo}
disabled={isInfoLoading}
>
<RefreshCcw className='h-4 w-4' />
Refresh
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<div className='space-y-4'>
<div className='space-y-2'>
<Skeleton className='h-8 w-56 max-w-full' />
<Skeleton className='h-4 w-80 max-w-full' />
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div className='space-y-2'>
<Skeleton className='h-3 w-16' />
<Skeleton className='h-5 w-24' />
</div>
<div className='space-y-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-40 max-w-full' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-12' />
<Skeleton className='h-5 w-full max-w-[26rem]' />
</div>
<div className='space-y-2 sm:col-span-2'>
<Skeleton className='h-3 w-10' />
<div className='flex items-center gap-2'>
<Skeleton className='h-5 max-w-[28rem] flex-1' />
<Skeleton className='h-7 w-7 rounded-md' />
</div>
</div>
</dl>
<div className='space-y-2'>
<Skeleton className='h-3 w-20' />
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`mint-skeleton-${index}`}
className='h-6 w-28 rounded-full'
/>
))}
</div>
</div>
</div>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
Unable to reach /v1/info at {normalizedBaseUrl}
</p>
)}
{nodeInfo && (
<>
<div className='space-y-2'>
<p className='text-2xl font-medium'>{nodeInfo.name}</p>
<p className='text-muted-foreground text-sm'>
{nodeInfo.description}
</p>
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
{nodeInfo.version}
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.http_url || normalizedBaseUrl}
</dd>
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.onion_url}
</dd>
</div>
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 font-mono text-sm break-all'>
{nodeInfo.npub}
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(nodeInfo.npub ?? '')}
>
<Copy className='h-4 w-4' />
</Button>
</dd>
</div>
)}
</dl>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs tracking-wide'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints?.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
variant='secondary'
className='font-mono text-xs'
>
{mint}
</Badge>
))
) : (
<p className='text-muted-foreground text-sm'>
No mint list published
</p>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='text-lg'>Quick docs</CardTitle>
<span className='text-muted-foreground text-xs tracking-wide'>
curl-ready
</span>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center gap-2'>
<Input
value={baseUrl}
placeholder={normalizedBaseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
className='text-sm'
/>
<Button
variant='outline'
size='icon'
className='h-9 w-9'
onClick={() => handleCopy(normalizedBaseUrl)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
<ScrollArea className='bg-muted w-full rounded-lg border'>
<pre className='w-max min-w-full p-4 font-mono text-sm leading-6 whitespace-pre'>
{curlSnippet}
</pre>
<ScrollBar orientation='horizontal' />
</ScrollArea>
<div className='flex gap-2'>
<Button
variant='secondary'
className='gap-2'
onClick={() => handleCopy(curlSnippet)}
>
<Copy className='h-4 w-4' />
Copy curl
</Button>
<Button variant='outline' asChild className='gap-2'>
<a
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<Terminal className='h-4 w-4' />
Full docs
</a>
</Button>
</div>
</CardContent>
</Card>
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
<TabsTrigger value='management'>Key Management</TabsTrigger>
</TabsList>
<TabsContent value='cashu' className='space-y-4'>
<CashuPaymentWorkflow
baseUrl={normalizedBaseUrl}
onApiKeyCreated={handleApiKeyCreated}
/>
</TabsContent>
<TabsContent value='lightning' className='space-y-4'>
<LightningPaymentWorkflow
baseUrl={normalizedBaseUrl}
onApiKeyCreated={handleApiKeyCreated}
/>
</TabsContent>
<TabsContent value='management' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
onRefundComplete={handleRefundComplete}
/>
{refundToken && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>Refund Complete</CardTitle>
</CardHeader>
<CardContent className='space-y-2'>
<div className='bg-muted/30 space-y-2 rounded-lg border p-4'>
<div className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={15}
className='font-mono text-xs'
/>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
onApiKeyChange={handleApiKeyChanged}
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
/>
</TabsContent>
</Tabs>
</main>
</div>
);
}