This commit is contained in:
Brian Murray 2026-08-08 08:05:23 -05:00 committed by GitHub
commit 6cb81d13ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 11602 additions and 801 deletions

1
app/.gitignore vendored
View file

@ -21,3 +21,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vercel

View file

@ -4,6 +4,9 @@
"description": "Lightning Terminal",
"repository": "https://github.com/lightninglabs/lightning-terminal",
"private": true,
"engines": {
"node": ">=18.x <=20.x"
},
"scripts": {
"start": "BROWSER=none react-scripts start",
"develop": "REACT_APP_USE_SAMPLE_DATA=true yarn start",
@ -22,6 +25,7 @@
"@emotion/react": "11.4.0",
"@emotion/styled": "11.3.0",
"@improbable-eng/grpc-web": "0.14.0",
"@lightninglabs/lnc-web": "^0.3.5-alpha",
"@types/react-collapse": "^5.0.1",
"big.js": "6.1.1",
"bootstrap": "4.6.1",
@ -36,10 +40,11 @@
"i18next-browser-languagedetector": "5.0.0",
"lodash": "4.18.1",
"lottie-web": "5.7.0",
"lucide-react": "^0.575.0",
"mobx": "6.3.2",
"mobx-react-lite": "3.2.0",
"mobx-utils": "6.0.4",
"qrcode.react": "^3.1.0",
"qrcode.react": "^4.2.0",
"rc-dialog": "^8.9.0",
"rc-select": "11.5.0",
"rc-switch": "^4.0.0",
@ -53,7 +58,8 @@
"react-virtualized": "9.21.2",
"reactour": "1.18.0",
"semver": "^7.5.2",
"styled-components": "5.1.1"
"styled-components": "5.1.1",
"three": "0.160.0"
},
"devDependencies": {
"@storybook/addon-actions": "7.5.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
app/public/icons/Lit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

BIN
app/public/icons/nodl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
app/public/icons/start9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
app/public/icons/umbrel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -2,37 +2,17 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/icons/favicon-32x32.png" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png" />
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png" />
<link rel="apple-touch-icon" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png" />
<link
rel="apple-touch-icon"
sizes="144x144"
href="%PUBLIC_URL%/icons/icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="192x192"
href="%PUBLIC_URL%/icons/icon-192x192.png"
/>
<link
rel="apple-touch-icon"
sizes="256x256"
href="%PUBLIC_URL%/icons/icon-256x256.png"
/>
<link
rel="apple-touch-icon"
sizes="384x384"
href="%PUBLIC_URL%/icons/icon-384x384.png"
/>
<link
rel="apple-touch-icon"
sizes="512x512"
href="%PUBLIC_URL%/icons/icon-512x512.png"
/>
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/favicon-16x16.png" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Lightning" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta name="description" content="Visualize the Lightning Network, manage channels, and connect with the decentralized payment layer." />
<title>Lightning Terminal</title>
</head>
<body>

View file

@ -1,10 +1,11 @@
{
"name": "Lightning Labs",
"name": "Lightning Terminal",
"short_name": "Lightning",
"description": "Visualize the Lightning Network, manage channels, and connect with the decentralized payment layer.",
"start_url": "/",
"background_color": "#ffffff",
"theme_color": "#57038D",
"display": "minimal-ui",
"background_color": "#0a0c12",
"theme_color": "#6366f1",
"display": "standalone",
"icons": [
{
"src": "icons/icon-48x48.png",
@ -29,7 +30,8 @@
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-256x256.png",
@ -44,7 +46,8 @@
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
]
}

View file

@ -1,5 +1,7 @@
@charset "utf-8";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
// required bootstrap styles
@import '../node_modules/bootstrap/scss/functions';
@import '../node_modules/bootstrap/scss/variables';
@ -23,68 +25,17 @@
// react-toastify styles
@import '../node_modules/react-toastify/dist/ReactToastify.css';
@font-face {
font-family: 'OpenSans Light';
src: url('./assets/fonts/OpenSans-Light.ttf') format('truetype');
font-style: normal;
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: 'OpenSans Regular';
src: url('./assets/fonts/OpenSans-Regular.ttf') format('truetype');
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'OpenSans SemiBold';
src: url('./assets/fonts/OpenSans-SemiBold.ttf') format('truetype');
font-style: normal;
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: 'OpenSans Bold';
src: url('./assets/fonts/OpenSans-Bold.ttf') format('truetype');
font-style: normal;
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: 'OpenSans ExtraBold';
src: url('./assets/fonts/OpenSans-ExtraBold.ttf') format('truetype');
font-style: normal;
font-weight: 800;
font-display: swap;
}
@font-face {
font-family: 'WorkSans Light';
src: url('./assets/fonts/WorkSans-Light.ttf') format('truetype');
font-style: normal;
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: 'WorkSans Medium';
src: url('./assets/fonts/WorkSans-Medium.ttf') format('truetype');
font-style: normal;
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'WorkSans SemiBold';
src: url('./assets/fonts/WorkSans-SemiBold.ttf') format('truetype');
font-style: normal;
font-weight: 600;
font-display: swap;
}
html,
body,
#root {
background-color: #252f4a;
background-color: #0d1017;
width: 100%;
min-width: 1024px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

View file

@ -2,7 +2,13 @@ import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Layout } from 'components/layout';
const LazyWelcomePage = React.lazy(() => import('components/auth/WelcomePage'));
const LazyAuthPage = React.lazy(() => import('components/auth/AuthPage'));
const LazyConnectNodePage = React.lazy(() => import('components/auth/ConnectNodePage'));
const LazyGetNodePage = React.lazy(() => import('components/auth/GetNodePage'));
const LazyExploreNetworkPage = React.lazy(
() => import('components/auth/ExploreNetworkPage'),
);
const LazyLoopPage = React.lazy(() => import('components/loop/LoopPage'));
const LazyHomePage = React.lazy(() => import('components/home/HomePage'));
const LazyHistoryPage = React.lazy(() => import('components/history/HistoryPage'));
@ -16,7 +22,11 @@ const LazyCustomSessionPage = React.lazy(
const AppRoutes: React.FC = () => {
return (
<Routes>
<Route path="/" element={<LazyAuthPage />} />
<Route path="/" element={<LazyWelcomePage />} />
<Route path="auth" element={<LazyAuthPage />} />
<Route path="connect-node" element={<LazyConnectNodePage />} />
<Route path="get-node" element={<LazyGetNodePage />} />
<Route path="explore-network" element={<LazyExploreNetworkPage />} />
<Route>
<Route
path="home"

View file

@ -0,0 +1,41 @@
const mockRpcMethod = jest.fn().mockResolvedValue({});
const mockLightning = {
getInfo: mockRpcMethod,
channelBalance: mockRpcMethod,
walletBalance: mockRpcMethod,
listChannels: mockRpcMethod,
pendingChannels: mockRpcMethod,
getNodeInfo: mockRpcMethod,
describeGraph: mockRpcMethod,
getNetworkInfo: mockRpcMethod,
getChanInfo: mockRpcMethod,
listPayments: jest.fn().mockResolvedValue({ payments: [] }),
listInvoices: jest.fn().mockResolvedValue({ invoices: [] }),
addInvoice: mockRpcMethod,
decodePayReq: mockRpcMethod,
sendPaymentSync: mockRpcMethod,
newAddress: mockRpcMethod,
sendCoins: mockRpcMethod,
openChannelSync: mockRpcMethod,
subscribeTransactions: jest.fn(),
subscribeChannelEvents: jest.fn(),
subscribeInvoices: jest.fn(),
};
class LNC {
isConnected = false;
lnd = { lightning: mockLightning };
constructor(_opts?: any) {}
async connect() {
this.isConnected = true;
}
disconnect() {
this.isConnected = false;
}
}
export default LNC;

View file

@ -22,9 +22,11 @@ export const Placements = () => {
return (
<div style={{ textAlign: 'center', marginTop: 300 }}>
{placements.map(p => (
<Tip key={p} placement={p} overlay="Tip of the day">
<span style={{ margin: 10 }}>{p}</span>
</Tip>
<div key={p} style={{ display: 'inline-block' }}>
<Tip placement={p} overlay="Tip of the day">
<span style={{ margin: 10 }}>{p}</span>
</Tip>
</div>
))}
</div>
);

208
app/src/api/lncApi.ts Normal file
View file

@ -0,0 +1,208 @@
import LNC from '@lightninglabs/lnc-web';
import BaseApi from './base';
interface LndEvents {
transaction: any;
channel: any;
invoice: any;
}
const LNC_STORAGE_KEY = 'lnc-paired';
/**
* LNC returns native proto JSON field names while the rest of the app
* expects the protobuf-js `toObject()` convention where repeated fields
* are suffixed with `List`. This helper copies known array fields to
* their `*List` alias so both conventions work.
*/
function aliasArrayFields(obj: any, mapping: Record<string, string>): any {
if (!obj) return obj;
for (const [src, dest] of Object.entries(mapping)) {
if (obj[src] !== undefined && obj[dest] === undefined) {
obj[dest] = obj[src];
}
}
return obj;
}
class LncApi extends BaseApi<LndEvents> {
private _lnc: LNC;
constructor(lnc: LNC) {
super();
this._lnc = lnc;
}
get lnc() {
return this._lnc;
}
async getInfo() {
const res: any = await this._lnc.lnd.lightning.getInfo();
return aliasArrayFields(res, {
chains: 'chainsList',
uris: 'urisList',
features: 'featuresList',
});
}
async channelBalance() {
return this._lnc.lnd.lightning.channelBalance();
}
async walletBalance() {
return this._lnc.lnd.lightning.walletBalance();
}
async listChannels() {
const res: any = await this._lnc.lnd.lightning.listChannels();
return aliasArrayFields(res, { channels: 'channelsList' });
}
async pendingChannels() {
const res: any = await this._lnc.lnd.lightning.pendingChannels();
return aliasArrayFields(res, {
pendingOpenChannels: 'pendingOpenChannelsList',
pendingClosingChannels: 'pendingClosingChannelsList',
pendingForceClosingChannels: 'pendingForceClosingChannelsList',
waitingCloseChannels: 'waitingCloseChannelsList',
});
}
async getNodeInfo(pubkey: string) {
return this._lnc.lnd.lightning.getNodeInfo({
pubKey: pubkey,
includeChannels: true,
});
}
async describeGraph() {
return this._lnc.lnd.lightning.describeGraph({
includeUnannounced: false,
});
}
async getNetworkInfo() {
return this._lnc.lnd.lightning.getNetworkInfo();
}
async getChannelInfo(id: string) {
return this._lnc.lnd.lightning.getChanInfo({
chanId: id,
});
}
async listPayments() {
const res: any = await this._lnc.lnd.lightning.listPayments({
includeIncomplete: true,
maxPayments: '100',
});
return aliasArrayFields(res, { payments: 'paymentsList' });
}
async listInvoices() {
const res: any = await this._lnc.lnd.lightning.listInvoices({
numMaxInvoices: '100',
reversed: true,
});
return aliasArrayFields(res, { invoices: 'invoicesList' });
}
async addInvoice(amount: string, memo: string) {
return this._lnc.lnd.lightning.addInvoice({
value: amount,
memo,
});
}
async decodePayReq(payReq: string) {
return this._lnc.lnd.lightning.decodePayReq({
payReq,
});
}
async sendPaymentSync(payReq: string) {
return this._lnc.lnd.lightning.sendPaymentSync({
paymentRequest: payReq,
});
}
async newAddress() {
return this._lnc.lnd.lightning.newAddress({
type: 0 as any,
});
}
async sendCoins(addr: string, amount: string) {
return this._lnc.lnd.lightning.sendCoins({
addr,
amount,
});
}
async openChannelSync(nodePubkey: string, localFundingAmount: string) {
return this._lnc.lnd.lightning.openChannelSync({
nodePubkeyString: nodePubkey,
localFundingAmount,
});
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
private static _noop() {}
connectStreams() {
try {
this._lnc.lnd.lightning.subscribeTransactions(
{},
(tx: any) => this.emit('transaction', tx),
LncApi._noop,
);
} catch {
// subscription may fail if not supported
}
try {
this._lnc.lnd.lightning.subscribeChannelEvents(
{},
(ev: any) => this.emit('channel', ev),
LncApi._noop,
);
} catch {
// subscription may fail if not supported
}
try {
this._lnc.lnd.lightning.subscribeInvoices(
{},
(inv: any) => this.emit('invoice', inv),
LncApi._noop,
);
} catch {
// subscription may fail if not supported
}
}
static get isPaired(): boolean {
return localStorage.getItem(LNC_STORAGE_KEY) === 'true';
}
static markPaired() {
localStorage.setItem(LNC_STORAGE_KEY, 'true');
}
static clearPaired() {
localStorage.removeItem(LNC_STORAGE_KEY);
}
static isPairedFor(namespace: string): boolean {
return localStorage.getItem(`${LNC_STORAGE_KEY}-${namespace}`) === 'true';
}
static markPairedFor(namespace: string) {
localStorage.setItem(`${LNC_STORAGE_KEY}-${namespace}`, 'true');
}
static clearPairedFor(namespace: string) {
localStorage.removeItem(`${LNC_STORAGE_KEY}-${namespace}`);
}
}
export default LncApi;

View file

@ -7,6 +7,7 @@ import GrpcClient from './grpc';
interface LndEvents {
transaction: LND.Transaction.AsObject;
channel: LND.ChannelEventUpdate.AsObject;
invoice: LND.Invoice.AsObject;
}
/**
@ -71,10 +72,29 @@ class LndApi extends BaseApi<LndEvents> {
async getNodeInfo(pubkey: string): Promise<LND.NodeInfo.AsObject> {
const req = new LND.NodeInfoRequest();
req.setPubKey(pubkey);
req.setIncludeChannels(true);
const res = await this._grpc.request(Lightning.GetNodeInfo, req, this._meta);
return res.toObject();
}
/**
* call the LND `DescribeGraph` RPC and return the full network topology
*/
async describeGraph(): Promise<LND.ChannelGraph.AsObject> {
const req = new LND.ChannelGraphRequest();
const res = await this._grpc.request(Lightning.DescribeGraph, req, this._meta);
return res.toObject();
}
/**
* call the LND `GetNetworkInfo` RPC and return aggregate stats
*/
async getNetworkInfo(): Promise<LND.NetworkInfo.AsObject> {
const req = new LND.NetworkInfoRequest();
const res = await this._grpc.request(Lightning.GetNetworkInfo, req, this._meta);
return res.toObject();
}
/**
* call the LND `GetChanInfo` RPC and return the response
*/
@ -85,6 +105,79 @@ class LndApi extends BaseApi<LndEvents> {
return res.toObject();
}
/**
* call the LND `ListPayments` RPC and return the response
*/
async listPayments(): Promise<LND.ListPaymentsResponse.AsObject> {
const req = new LND.ListPaymentsRequest();
req.setIncludeIncomplete(true);
req.setMaxPayments('100');
const res = await this._grpc.request(Lightning.ListPayments, req, this._meta);
return res.toObject();
}
/**
* call the LND `ListInvoices` RPC and return the response
*/
async listInvoices(): Promise<LND.ListInvoiceResponse.AsObject> {
const req = new LND.ListInvoiceRequest();
req.setNumMaxInvoices('100');
req.setReversed(true);
const res = await this._grpc.request(Lightning.ListInvoices, req, this._meta);
return res.toObject();
}
async addInvoice(
amount: string,
memo: string,
): Promise<LND.AddInvoiceResponse.AsObject> {
const req = new LND.Invoice();
req.setValue(amount);
req.setMemo(memo);
const res = await this._grpc.request(Lightning.AddInvoice, req, this._meta);
return res.toObject();
}
async decodePayReq(payReq: string): Promise<LND.PayReq.AsObject> {
const req = new LND.PayReqString();
req.setPayReq(payReq);
const res = await this._grpc.request(Lightning.DecodePayReq, req, this._meta);
return res.toObject();
}
async sendPaymentSync(payReq: string): Promise<LND.SendResponse.AsObject> {
const req = new LND.SendRequest();
req.setPaymentRequest(payReq);
const res = await this._grpc.request(Lightning.SendPaymentSync, req, this._meta);
return res.toObject();
}
async newAddress(): Promise<LND.NewAddressResponse.AsObject> {
const req = new LND.NewAddressRequest();
req.setType(0); // WITNESS_PUBKEY_HASH (native segwit)
const res = await this._grpc.request(Lightning.NewAddress, req, this._meta);
return res.toObject();
}
async sendCoins(addr: string, amount: string): Promise<LND.SendCoinsResponse.AsObject> {
const req = new LND.SendCoinsRequest();
req.setAddr(addr);
req.setAmount(amount);
const res = await this._grpc.request(Lightning.SendCoins, req, this._meta);
return res.toObject();
}
async openChannelSync(
nodePubkey: string,
localFundingAmount: string,
): Promise<LND.ChannelPoint.AsObject> {
const req = new LND.OpenChannelRequest();
req.setNodePubkeyString(nodePubkey);
req.setLocalFundingAmount(localFundingAmount);
const res = await this._grpc.request(Lightning.OpenChannelSync, req, this._meta);
return res.toObject();
}
/**
* Connect to the LND streaming endpoints
*/
@ -101,6 +194,12 @@ class LndApi extends BaseApi<LndEvents> {
channelEvent => this.emit('channel', channelEvent.toObject()),
this._meta,
);
this._grpc.subscribe(
Lightning.SubscribeInvoices,
new LND.InvoiceSubscription(),
invoice => this.emit('invoice', invoice.toObject()),
this._meta,
);
}
}

View file

@ -17,74 +17,97 @@ const Styled = {
`,
Logo: styled(LogoImage)`
color: ${props => props.theme.colors.offWhite};
width: 80px;
height: 156px;
margin-bottom: 30px;
width: 48px;
height: 96px;
margin-bottom: 24px;
opacity: 0.8;
`,
Title: styled(HeaderOne)`
font-size: 75px;
margin-bottom: 30px;
text-transform: uppercase;
font-size: 36px;
margin-bottom: 8px;
letter-spacing: -0.03em;
font-weight: 600;
`,
Subtitle: styled.div`
width: 100%;
max-width: 500px;
margin-bottom: 80px;
max-width: 400px;
margin-bottom: 48px;
text-align: center;
color: ${props => props.theme.colors.gray};
font-size: 14px;
`,
Form: styled.form`
max-width: 550px;
max-width: 360px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
`,
Password: styled.input`
font-family: ${props => props.theme.fonts.work.light};
font-weight: 300;
font-size: ${props => props.theme.sizes.xxl};
font-family: 'Inter', sans-serif;
font-weight: 400;
font-size: 15px;
color: ${props => props.theme.colors.offWhite};
background-color: transparent;
border-width: 0;
border-bottom: 3px solid ${props => props.theme.colors.offWhite};
padding: 5px;
text-align: center;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px 16px;
width: 100%;
transition: border-color 0.15s ease;
&:active,
&:focus {
outline: none;
background-color: ${props => props.theme.colors.overlay};
border-bottom-color: ${props => props.theme.colors.white};
border-color: ${props => props.theme.colors.iris};
background-color: rgba(255, 255, 255, 0.06);
}
&::placeholder {
color: ${props => props.theme.colors.gray};
}
`,
Label: styled.label``,
Label: styled.label`
font-size: 13px;
color: ${props => props.theme.colors.gray};
`,
ErrMessage: styled.div`
width: 100%;
display: inline-block;
padding: 5px 0;
background-color: ${props => props.theme.colors.pink};
color: ${props => props.theme.colors.offWhite};
padding: 10px 14px;
background-color: rgba(244, 63, 94, 0.15);
color: ${props => props.theme.colors.pink};
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: 8px;
text-align: center;
font-size: 13px;
`,
ErrDetail: styled.div`
width: 100%;
display: inline-block;
padding: 5px 0;
color: ${props => props.theme.colors.offWhite};
padding: 6px 0;
color: ${props => props.theme.colors.gray};
text-align: center;
font-size: 12px;
`,
ErrDetailToggle: styled(Button)`
width: 100%;
padding: 5px 0;
padding: 4px 0;
background-color: transparent;
font-size: 12px;
`,
Submit: styled(Button)`
margin-top: 80px;
background-color: transparent;
margin-top: 8px;
width: 100%;
height: 40px;
background: ${props => props.theme.colors.iris};
border: none;
border-radius: 10px;
font-weight: 500;
font-size: 14px;
&:hover {
background: #5355d4;
color: white;
}
`,
};

View file

@ -0,0 +1,885 @@
import React, { useState } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { keyframes } from '@emotion/react';
import { useStore } from 'store';
import LncApi from 'api/lncApi';
import {
ArrowLeft,
Key,
Lock,
Loader2,
AlertCircle,
Copy,
Check,
Shield,
Zap,
Sparkles,
ChevronDown,
ChevronRight,
HelpCircle,
Server,
} from 'lucide-react';
const fadeUp = keyframes`
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const S = {
Wrapper: styled.div`
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
padding: 48px 24px 60px;
background: #030508;
color: #e5e7eb;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow-y: auto;
`,
Back: styled.button`
position: fixed;
top: 20px;
left: 20px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 8px;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #ffffff;
}
`,
Header: styled.div`
text-align: center;
margin-bottom: 28px;
animation: ${fadeUp} 0.5s ease both;
`,
PageLabel: styled.div`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.2em;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 10px;
`,
Title: styled.h1`
font-size: 32px;
font-weight: 600;
letter-spacing: -0.03em;
color: #ffffff;
margin: 0;
`,
Content: styled.div`
width: 100%;
max-width: 460px;
animation: ${fadeUp} 0.5s ease both;
animation-delay: 0.08s;
`,
MethodToggle: styled.div`
display: flex;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
padding: 3px;
margin-bottom: 20px;
`,
MethodBtn: styled.button<{ active?: boolean }>`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: 10px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background: ${p => (p.active ? 'rgba(255, 255, 255, 0.08)' : 'transparent')};
color: ${p => (p.active ? '#ffffff' : 'rgba(255, 255, 255, 0.3)')};
&:hover {
color: ${p => (p.active ? '#ffffff' : 'rgba(255, 255, 255, 0.5)')};
}
`,
Form: styled.form`
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
`,
Label: styled.label`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.3);
margin-bottom: -6px;
`,
Input: styled.input`
font-family: 'Inter', sans-serif;
font-size: 14px;
color: #ffffff;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 12px 14px;
width: 100%;
transition: all 0.15s ease;
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
}
&::placeholder {
color: rgba(255, 255, 255, 0.2);
}
`,
TextArea: styled.textarea`
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #ffffff;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 12px 14px;
width: 100%;
min-height: 52px;
resize: none;
transition: all 0.15s ease;
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
}
&::placeholder {
color: rgba(255, 255, 255, 0.2);
font-family: 'Inter', sans-serif;
}
`,
Btn: styled.button<{ variant?: 'primary' | 'outline' }>`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 44px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
background: ${p =>
p.variant === 'outline' ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.08)'};
color: ${p => (p.variant === 'outline' ? 'rgba(255,255,255,0.4)' : '#ffffff')};
&:hover {
background: ${p =>
p.variant === 'outline' ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.12)'};
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
`,
ErrorMsg: styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(244, 63, 94, 0.08);
color: #f87171;
border-radius: 10px;
font-size: 13px;
`,
Divider: styled.div`
display: flex;
align-items: center;
gap: 12px;
margin: 4px 0 8px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.12);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.05);
}
`,
HelpToggle: styled.button`
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-family: inherit;
font-size: 13px;
cursor: pointer;
padding: 6px 0;
margin-bottom: 6px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.45);
}
`,
HelpContent: styled.div`
animation: ${fadeUp} 0.3s ease both;
`,
AgentCard: styled.div`
background: rgba(18, 20, 28, 0.92);
border-radius: 14px;
padding: 18px;
margin-bottom: 10px;
`,
AgentCardHeader: styled.div`
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 6px;
`,
AgentCardIcon: styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 7px;
background: rgba(139, 92, 246, 0.12);
color: rgba(167, 139, 250, 0.9);
`,
AgentCardTitle: styled.div`
font-size: 13px;
font-weight: 600;
color: #ffffff;
`,
AgentCardDesc: styled.p`
font-size: 12px;
color: rgba(255, 255, 255, 0.28);
line-height: 1.5;
margin: 0 0 10px;
`,
PromptBox: styled.div`
position: relative;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(139, 92, 246, 0.1);
border-radius: 10px;
padding: 12px 40px 12px 12px;
`,
PromptText: styled.div`
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.4);
white-space: pre-wrap;
word-break: break-word;
`,
CopyBtn: styled.button<{ copied?: boolean }>`
position: absolute;
top: 9px;
right: 9px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: ${p =>
p.copied ? 'rgba(52, 211, 153, 0.15)' : 'rgba(255, 255, 255, 0.06)'};
color: ${p => (p.copied ? 'rgba(52, 211, 153, 0.9)' : 'rgba(255, 255, 255, 0.25)')};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${p =>
p.copied ? 'rgba(52, 211, 153, 0.2)' : 'rgba(255, 255, 255, 0.1)'};
color: ${p => (p.copied ? 'rgba(52, 211, 153, 1)' : 'rgba(255, 255, 255, 0.5)')};
}
`,
AccordionGroup: styled.div`
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
`,
AccordionItem: styled.div`
background: rgba(18, 20, 28, 0.92);
border-radius: 12px;
overflow: hidden;
`,
AccordionHeader: styled.button`
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: none;
border: none;
color: #ffffff;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
`,
AccordionLogo: styled.img`
width: 20px;
height: 20px;
border-radius: 5px;
object-fit: cover;
`,
AccordionName: styled.span`
flex: 1;
font-size: 12px;
`,
AccordionChevron: styled.div<{ open?: boolean }>`
color: rgba(255, 255, 255, 0.15);
transform: ${p => (p.open ? 'rotate(90deg)' : 'rotate(0)')};
transition: transform 0.15s ease;
display: flex;
align-items: center;
`,
AccordionBody: styled.div`
padding: 0 14px 14px;
font-size: 12px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.35);
ol {
padding-left: 16px;
margin: 0;
}
li {
margin-bottom: 2px;
}
strong {
color: rgba(255, 255, 255, 0.55);
}
`,
CommandBlock: styled.div`
position: relative;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 10px 36px 10px 12px;
margin: 6px 0 8px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
line-height: 1.5;
word-break: break-all;
white-space: pre-wrap;
`,
SmallCopyBtn: styled.button`
position: absolute;
top: 7px;
right: 7px;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 5px;
padding: 4px;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.15s ease;
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
`,
SecurityNote: styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: rgba(255, 255, 255, 0.2);
margin-top: 4px;
svg {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.15);
}
`,
ReconnectPanel: styled.div`
margin-bottom: 16px;
`,
ReconnectLabel: styled.div`
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 12px;
svg {
color: rgba(255, 255, 255, 0.3);
}
`,
NewSessionBtn: styled.button`
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-family: inherit;
font-size: 13px;
cursor: pointer;
padding: 6px 0;
margin-top: 8px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.45);
}
`,
FooterLink: styled.button`
display: flex;
align-items: center;
gap: 7px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-family: inherit;
font-size: 13px;
cursor: pointer;
padding: 6px 0;
margin-top: 8px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.45);
}
`,
Spinner: styled(Loader2)`
animation: ${spin} 1s linear infinite;
`,
};
const CMD = 'litcli sessions add --label="Lightning Terminal Web" --type admin';
const AGENT_PROMPT = `I need to generate a Lightning Node Connect pairing phrase from my running litd (Lightning Terminal) instance. Run this command:
litcli sessions add --label="Lightning Terminal Web" --type admin
Give me the 10-word pairing phrase from the output so I can connect my node.`;
const providerHelp: {
id: string;
name: string;
logo: string;
steps: React.ReactNode;
}[] = [
{
id: 'voltage',
name: 'Voltage',
logo: '/icons/voltage.png',
steps: (
<ol>
<li>
Log in to your <strong>Voltage dashboard</strong>
</li>
<li>
Select your node &rarr; <strong>Connect</strong>
</li>
<li>
Under Lightning Terminal, click <strong>Reveal Pairing Phrase</strong>
</li>
<li>Copy the 10-word phrase</li>
</ol>
),
},
{
id: 'umbrel',
name: 'Umbrel',
logo: '/icons/umbrel.png',
steps: (
<ol>
<li>
Open <strong>Lightning Terminal</strong> on your Umbrel
</li>
<li>
Go to <strong>Lightning Node Connect</strong> in settings
</li>
<li>
Click <strong>Create</strong> to generate a session
</li>
<li>Copy the 10-word phrase</li>
</ol>
),
},
{
id: 'start9',
name: 'Start9',
logo: '/icons/start9.png',
steps: (
<ol>
<li>
Open <strong>Lightning Terminal</strong> on your Start9
</li>
<li>
Navigate to <strong>Properties</strong>
</li>
<li>
Find <strong>LNC Pairing Phrase</strong>
</li>
<li>Copy the 10-word phrase</li>
</ol>
),
},
{
id: 'self',
name: 'Self-Hosted (litd)',
logo: '/icons/Lit-dark.png',
steps: null,
},
];
const ConnectNodePage: React.FC = () => {
const store = useStore();
const [method, setMethod] = useState<'lnc' | 'password'>('lnc');
const [pairingPhrase, setPairingPhrase] = useState('');
const [lncPassword, setLncPassword] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showNewSession, setShowNewSession] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [openProvider, setOpenProvider] = useState<string | null>(null);
const [copiedAgent, setCopiedAgent] = useState(false);
const [copiedCmd, setCopiedCmd] = useState(false);
const hasSavedSession = LncApi.isPaired;
const copyAgentPrompt = async () => {
try {
await navigator.clipboard.writeText(AGENT_PROMPT);
setCopiedAgent(true);
setTimeout(() => setCopiedAgent(false), 2000);
} catch {
/* noop */
}
};
const copyCommand = async () => {
try {
await navigator.clipboard.writeText(CMD);
setCopiedCmd(true);
setTimeout(() => setCopiedCmd(false), 2000);
} catch {
/* noop */
}
};
const handleLncConnect = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
if (hasSavedSession && !showNewSession && !pairingPhrase.trim()) {
await store.authStore.reconnectLnc(lncPassword);
} else {
if (showNewSession) {
LncApi.clearPaired();
}
await store.authStore.loginWithLnc(pairingPhrase, lncPassword);
}
} catch (err: any) {
const msg = err?.message || 'Connection failed';
setError(msg);
if (msg.includes('expired') || msg.includes('Session')) {
setShowNewSession(true);
setLncPassword('');
}
} finally {
setLoading(false);
}
};
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) return;
setLoading(true);
setError('');
try {
await store.authStore.login(password);
} catch (err: any) {
setError(err?.message || 'Connection failed');
} finally {
setLoading(false);
}
};
if (!store.initialized) return null;
const showLncForm = !hasSavedSession || showNewSession;
return (
<S.Wrapper>
<S.Back onClick={() => store.appView.goTo('/')}>
<ArrowLeft size={16} />
</S.Back>
<S.Header>
<S.PageLabel>Connect</S.PageLabel>
<S.Title>Connect Your Node</S.Title>
</S.Header>
<S.Content>
<S.MethodToggle>
<S.MethodBtn
active={method === 'lnc'}
onClick={() => {
setMethod('lnc');
setError('');
}}
>
<Key size={14} />
Lightning Node Connect
</S.MethodBtn>
<S.MethodBtn
active={method === 'password'}
onClick={() => {
setMethod('password');
setError('');
}}
>
<Lock size={14} />
Direct (Password)
</S.MethodBtn>
</S.MethodToggle>
{method === 'lnc' && (
<>
{hasSavedSession && !showNewSession ? (
<S.ReconnectPanel>
<S.ReconnectLabel>
<Zap size={13} />
Saved session found enter your password to reconnect.
</S.ReconnectLabel>
<S.Form onSubmit={handleLncConnect}>
<S.Input
type="password"
placeholder="LNC password"
value={lncPassword}
onChange={e => {
setLncPassword(e.target.value);
setError('');
}}
autoFocus
/>
{error && (
<S.ErrorMsg>
<AlertCircle size={14} /> {error}
</S.ErrorMsg>
)}
<S.Btn type="submit" disabled={loading || !lncPassword.trim()}>
{loading ? (
<>
<S.Spinner size={16} />
Connecting...
</>
) : (
'Reconnect'
)}
</S.Btn>
</S.Form>
<S.NewSessionBtn onClick={() => setShowNewSession(true)}>
<Key size={13} />
Use a new pairing phrase instead
<ChevronRight size={13} style={{ marginLeft: 2 }} />
</S.NewSessionBtn>
</S.ReconnectPanel>
) : (
<S.Form onSubmit={handleLncConnect}>
<S.Label>Pairing Phrase</S.Label>
<S.TextArea
placeholder="Enter your 10-word pairing phrase"
value={pairingPhrase}
onChange={e => {
setPairingPhrase(e.target.value);
setError('');
}}
autoFocus
/>
<S.Label>Password</S.Label>
<S.Input
type="password"
placeholder="Choose a password to encrypt this session"
value={lncPassword}
onChange={e => {
setLncPassword(e.target.value);
setError('');
}}
/>
{error && (
<S.ErrorMsg>
<AlertCircle size={14} /> {error}
</S.ErrorMsg>
)}
<S.Btn
type="submit"
disabled={loading || !pairingPhrase.trim() || !lncPassword.trim()}
>
{loading ? (
<>
<S.Spinner size={16} />
Connecting...
</>
) : (
'Connect'
)}
</S.Btn>
</S.Form>
)}
{showLncForm && (
<>
<S.HelpToggle onClick={() => setHelpOpen(!helpOpen)}>
<HelpCircle size={13} />
How to get your pairing phrase
{helpOpen ? (
<ChevronDown size={13} style={{ marginLeft: 2 }} />
) : (
<ChevronRight size={13} style={{ marginLeft: 2 }} />
)}
</S.HelpToggle>
{helpOpen && (
<S.HelpContent>
<S.AgentCard>
<S.AgentCardHeader>
<S.AgentCardIcon>
<Sparkles size={13} />
</S.AgentCardIcon>
<S.AgentCardTitle>Generate with your AI agent</S.AgentCardTitle>
</S.AgentCardHeader>
<S.AgentCardDesc>
Copy this prompt into Cursor, Claude Code, or any AI agent.
</S.AgentCardDesc>
<S.PromptBox>
<S.PromptText>{AGENT_PROMPT}</S.PromptText>
<S.CopyBtn
onClick={copyAgentPrompt}
copied={copiedAgent}
title="Copy prompt"
>
{copiedAgent ? <Check size={12} /> : <Copy size={12} />}
</S.CopyBtn>
</S.PromptBox>
</S.AgentCard>
<S.AccordionGroup>
{providerHelp.map(p => (
<S.AccordionItem key={p.id}>
<S.AccordionHeader
onClick={() =>
setOpenProvider(prev => (prev === p.id ? null : p.id))
}
>
<S.AccordionLogo src={p.logo} alt={p.name} />
<S.AccordionName>{p.name}</S.AccordionName>
<S.AccordionChevron open={openProvider === p.id}>
<ChevronRight size={13} />
</S.AccordionChevron>
</S.AccordionHeader>
{openProvider === p.id && (
<S.AccordionBody>
{p.steps ? (
p.steps
) : (
<>
Run this on the machine where litd is running:
<S.CommandBlock>
{CMD}
<S.SmallCopyBtn onClick={copyCommand}>
{copiedCmd ? (
<Check size={11} />
) : (
<Copy size={11} />
)}
</S.SmallCopyBtn>
</S.CommandBlock>
Copy the <strong>10-word pairing phrase</strong> from
the output.
</>
)}
</S.AccordionBody>
)}
</S.AccordionItem>
))}
</S.AccordionGroup>
</S.HelpContent>
)}
</>
)}
</>
)}
{method === 'password' && (
<>
<S.Form onSubmit={handlePasswordSubmit}>
<S.Label>UI Password</S.Label>
<S.Input
type="password"
placeholder="Enter your litd password"
value={password}
onChange={e => {
setPassword(e.target.value);
setError('');
}}
autoFocus
/>
{error && (
<S.ErrorMsg>
<AlertCircle size={14} /> {error}
</S.ErrorMsg>
)}
<S.Btn type="submit" disabled={loading || !password.trim()}>
{loading ? (
<>
<S.Spinner size={16} />
Connecting...
</>
) : (
'Connect'
)}
</S.Btn>
</S.Form>
<S.SecurityNote>
<Shield size={12} />
Direct connection to litd on the same host.
</S.SecurityNote>
</>
)}
<S.FooterLink onClick={() => store.appView.goTo('/get-node')}>
<Server size={13} />
Don&apos;t have a node? Get one
<ChevronRight size={13} style={{ marginLeft: 2 }} />
</S.FooterLink>
</S.Content>
</S.Wrapper>
);
};
export default observer(ConnectNodePage);

View file

@ -0,0 +1,861 @@
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - three.js types require newer TS
import * as THREE from 'three';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { useStore } from 'store';
import {
ArrowLeft,
Link2,
Server,
Loader2,
Copy,
Check,
X,
ExternalLink,
} from 'lucide-react';
interface NodeData {
id: string;
alias: string;
x: number;
y: number;
z: number;
isCenter: boolean;
capacity: number;
color: string;
channelCount: number;
}
const S = {
Wrapper: styled.div`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #030508;
`,
Canvas: styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
`,
TopBar: styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(to bottom, rgba(3, 5, 8, 0.9) 0%, transparent 100%);
pointer-events: none;
> * {
pointer-events: auto;
}
`,
BackBtn: styled.button`
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 8px;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #ffffff;
}
`,
Title: styled.div`
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.15em;
color: rgba(255, 255, 255, 0.35);
`,
Stats: styled.div`
display: flex;
gap: 16px;
font-size: 12px;
color: #6b7280;
`,
StatVal: styled.span`
color: #e5e7eb;
font-weight: 500;
margin-left: 4px;
`,
Banner: styled.div`
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
background: rgba(19, 22, 32, 0.92);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 12px;
backdrop-filter: blur(12px);
max-width: 540px;
width: calc(100% - 40px);
`,
BannerText: styled.div`
flex: 1;
font-size: 13px;
color: #9ca3af;
line-height: 1.4;
strong {
color: #e5e7eb;
}
`,
BannerBtn: styled.button<{ variant?: 'primary' | 'secondary' }>`
display: flex;
align-items: center;
gap: 5px;
padding: 8px 14px;
border-radius: 8px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
border: none;
${p =>
p.variant === 'primary'
? `
background: #6366f1;
color: white;
&:hover { background: #5355d4; }
`
: `
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover { background: rgba(255, 255, 255, 0.1); }
`}
`,
BannerClose: styled.button`
background: none;
border: none;
color: #4b5563;
cursor: pointer;
padding: 4px;
display: flex;
&:hover {
color: #9ca3af;
}
`,
Loading: styled.div`
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #030508;
color: #6b7280;
font-size: 14px;
`,
Tooltip: styled.div`
position: absolute;
z-index: 4;
background: rgba(19, 22, 32, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 12px 14px;
pointer-events: none;
min-width: 200px;
max-width: 260px;
backdrop-filter: blur(8px);
`,
TtAlias: styled.div`
font-size: 13px;
font-weight: 600;
color: #e5e7eb;
margin-bottom: 6px;
`,
TtRow: styled.div`
font-size: 11px;
color: #6b7280;
margin-bottom: 3px;
span {
color: #9ca3af;
float: right;
}
`,
DetailPanel: styled.div`
position: absolute;
top: 60px;
right: 16px;
z-index: 3;
width: 280px;
background: rgba(19, 22, 32, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 18px;
backdrop-filter: blur(12px);
`,
DpClose: styled.button`
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px;
&:hover {
color: #e5e7eb;
}
`,
DpAlias: styled.div`
font-size: 15px;
font-weight: 600;
color: #e5e7eb;
margin-bottom: 4px;
padding-right: 24px;
`,
DpPubkey: styled.div`
font-size: 11px;
color: #6b7280;
font-family: 'SF Mono', 'Fira Code', monospace;
word-break: break-all;
margin-bottom: 14px;
display: flex;
align-items: flex-start;
gap: 6px;
`,
DpCopyBtn: styled.button`
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 2px;
flex-shrink: 0;
&:hover {
color: #e5e7eb;
}
`,
DpStats: styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 14px;
`,
DpStat: styled.div`
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 8px 10px;
`,
DpStatLabel: styled.div`
font-size: 10px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
`,
DpStatVal: styled.div`
font-size: 13px;
font-weight: 500;
color: #e5e7eb;
`,
DpActions: styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`,
DpActionBtn: styled.button<{ variant?: string }>`
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px;
border-radius: 8px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
${p =>
p.variant === 'primary'
? `
background: #6366f1;
color: white;
border: none;
&:hover { background: #5355d4; }
`
: `
background: rgba(255, 255, 255, 0.04);
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover { background: rgba(255, 255, 255, 0.08); }
`}
`,
};
const formatSats = (sats: number): string => {
if (sats >= 100_000_000) {
const btc = sats / 100_000_000;
return Math.round(btc).toLocaleString() + ' BTC';
}
if (sats >= 1_000_000) {
const m = sats / 1_000_000;
return Math.round(m).toLocaleString() + 'M sats';
}
return sats.toLocaleString() + ' sats';
};
const ExploreNetworkPage: React.FC = () => {
const { networkGraphStore, appView } = useStore();
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<any>(null);
const [bannerDismissed, setBannerDismissed] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
const [hoveredNode, setHoveredNode] = useState<NodeData | null>(null);
const hoveredRef = useRef<NodeData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [copiedPk, setCopiedPk] = useState(false);
useEffect(() => {
networkGraphStore.fetchGraph();
}, [networkGraphStore]);
const nodes: NodeData[] = useMemo(() => {
if (networkGraphStore.nodes.length === 0) return [];
const mData = networkGraphStore.mempoolData;
const count = networkGraphStore.nodes.length;
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
return networkGraphStore.nodes.map((n, i) => {
const md = mData.get(n.pubKey);
const t = goldenAngle * i;
const yNorm = 1 - (2 * i) / (count - 1 || 1);
const radY = Math.sqrt(1 - yNorm * yNorm);
const spread = 7 + (i % 5) * 0.4;
return {
id: n.pubKey,
alias: n.alias || n.pubKey.substring(0, 12) + '...',
x: Math.cos(t) * radY * spread,
y: yNorm * spread * 0.6,
z: Math.sin(t) * radY * spread,
isCenter: false,
capacity: md?.capacity || 0,
color: n.color || '#8b5cf6',
channelCount: md?.channels || 0,
};
});
}, [networkGraphStore.nodes, networkGraphStore.mempoolData]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
if (!sceneRef.current) return;
const { camera, raycaster, mouse, nodeMeshes } = sceneRef.current;
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const meshes = Array.from(nodeMeshes.values()).map((v: any) => v.mesh);
const hits = raycaster.intersectObjects(meshes);
if (hits.length > 0) {
for (const [, v] of nodeMeshes) {
if ((v as any).mesh === hits[0].object) {
const nd = (v as any).data;
hoveredRef.current = nd;
setHoveredNode(nd);
return;
}
}
}
hoveredRef.current = null;
setHoveredNode(null);
}, []);
const handleClick = useCallback(() => {
if (hoveredNode) {
setSelectedNode(selectedNode?.id === hoveredNode.id ? null : hoveredNode);
}
}, [hoveredNode, selectedNode]);
const handleCopyPk = useCallback((pk: string) => {
navigator.clipboard.writeText(pk);
setCopiedPk(true);
setTimeout(() => setCopiedPk(false), 2000);
}, []);
// Three.js scene
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || nodes.length === 0) return;
const scene = new THREE.Scene();
const cam = new THREE.PerspectiveCamera(
50,
canvas.clientWidth / canvas.clientHeight,
0.1,
500,
);
cam.position.set(0, 1, 18);
cam.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x030508, 1);
const group = new THREE.Group();
scene.add(group);
scene.add(new THREE.AmbientLight(0x8080a0, 0.3));
// Background stars
const bgN = 2500;
const bgArr = new Float32Array(bgN * 3);
for (let i = 0; i < bgN; i++) {
const t = Math.random() * Math.PI * 2;
const p = Math.acos(2 * Math.random() - 1);
const r = 40 + Math.random() * 120;
bgArr[i * 3] = r * Math.sin(p) * Math.cos(t);
bgArr[i * 3 + 1] = r * Math.sin(p) * Math.sin(t);
bgArr[i * 3 + 2] = r * Math.cos(p);
}
const bgGeo = new THREE.BufferGeometry();
bgGeo.setAttribute('position', new THREE.BufferAttribute(bgArr, 3));
scene.add(
new THREE.Points(
bgGeo,
new THREE.PointsMaterial({
color: 0xc0c8e0,
size: 0.12,
transparent: true,
opacity: 0.35,
sizeAttenuation: true,
}),
),
);
const hexToInt = (h: string) => parseInt(h.replace('#', ''), 16) || 0x8b5cf6;
const caps = nodes.map(n => n.capacity).filter(c => c > 0);
const logMin = caps.length > 0 ? Math.log(Math.min(...caps) + 1) : 0;
const logMax = caps.length > 0 ? Math.log(Math.max(...caps) + 1) : 1;
const logRange = logMax - logMin || 1;
const nodeMeshes = new Map<string, { mesh: THREE.Mesh; data: NodeData }>();
nodes.forEach(node => {
const logCap = node.capacity > 0 ? Math.log(node.capacity + 1) : 0;
const capNorm = (logCap - logMin) / logRange;
const starR = 0.015 + capNorm * 0.07;
const col = hexToInt(node.color);
const core = new THREE.Mesh(
new THREE.SphereGeometry(starR, 12, 12),
new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.85,
}),
);
core.position.set(node.x, node.y, node.z);
group.add(core);
const glowMul = 2.0 + capNorm * 1.5;
const glow = new THREE.Mesh(
new THREE.SphereGeometry(starR * glowMul, 12, 12),
new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.02 + capNorm * 0.03,
}),
);
glow.position.set(node.x, node.y, node.z);
group.add(glow);
const hitR = Math.max(starR * 2.5, 0.2);
const hit = new THREE.Mesh(
new THREE.SphereGeometry(hitR, 8, 8),
new THREE.MeshBasicMaterial({ visible: false }),
);
hit.position.set(node.x, node.y, node.z);
group.add(hit);
nodeMeshes.set(node.id, { mesh: hit, data: node });
});
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Lightning bolt pool
const BOLT_COUNT = 4;
const BOLT_SEGS = 48;
const bolts: Array<{
line: THREE.Line;
glowPts: THREE.Points;
fromPos: THREE.Vector3;
toPos: THREE.Vector3;
progress: number;
speed: number;
alive: boolean;
}> = [];
if (nodes.length > 1) {
for (let i = 0; i < BOLT_COUNT; i++) {
const vCount = BOLT_SEGS + 1;
const lPos = new Float32Array(vCount * 3);
const lCol = new Float32Array(vCount * 3);
const lGeo = new THREE.BufferGeometry();
lGeo.setAttribute('position', new THREE.BufferAttribute(lPos, 3));
lGeo.setAttribute('color', new THREE.BufferAttribute(lCol, 3));
const lMat = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const line = new THREE.Line(lGeo, lMat);
line.visible = false;
group.add(line);
const gPos = new Float32Array(vCount * 3);
const gCol = new Float32Array(vCount * 3);
const gGeo = new THREE.BufferGeometry();
gGeo.setAttribute('position', new THREE.BufferAttribute(gPos, 3));
gGeo.setAttribute('color', new THREE.BufferAttribute(gCol, 3));
const gMat = new THREE.PointsMaterial({
vertexColors: true,
size: 0.08,
sizeAttenuation: true,
transparent: true,
opacity: 0.15,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glowPts = new THREE.Points(gGeo, gMat);
glowPts.visible = false;
group.add(glowPts);
bolts.push({
line,
glowPts,
fromPos: new THREE.Vector3(),
toPos: new THREE.Vector3(),
progress: 0,
speed: 0,
alive: false,
});
}
}
const fireBolt = () => {
const idle = bolts.find(b => !b.alive);
if (!idle || nodes.length < 2) return;
const a = Math.floor(Math.random() * nodes.length);
let b = Math.floor(Math.random() * (nodes.length - 1));
if (b >= a) b++;
const from = nodes[a];
const to = nodes[b];
idle.fromPos.set(from.x, from.y, from.z);
idle.toPos.set(to.x, to.y, to.z);
const dir = idle.toPos.clone().sub(idle.fromPos);
const len = dir.length();
dir.normalize();
const ref =
Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
const p1 = new THREE.Vector3().crossVectors(dir, ref).normalize();
const p2 = new THREE.Vector3().crossVectors(dir, p1).normalize();
const lAttr = idle.line.geometry.attributes.position as THREE.BufferAttribute;
const gAttr = idle.glowPts.geometry.attributes.position as THREE.BufferAttribute;
const jitter = len * 0.08;
for (let s = 0; s <= BOLT_SEGS; s++) {
const t = s / BOLT_SEGS;
const pt = idle.fromPos.clone().lerp(idle.toPos, t);
if (s > 0 && s < BOLT_SEGS) {
const j = jitter * (1 - Math.abs(t - 0.5) * 2) * 0.6;
pt.addScaledVector(p1, (Math.random() - 0.5) * j);
pt.addScaledVector(p2, (Math.random() - 0.5) * j);
}
lAttr.setXYZ(s, pt.x, pt.y, pt.z);
gAttr.setXYZ(s, pt.x, pt.y, pt.z);
}
lAttr.needsUpdate = true;
gAttr.needsUpdate = true;
idle.progress = 0;
idle.speed = 0.6 + Math.random() * 0.4;
idle.alive = true;
idle.line.visible = true;
idle.glowPts.visible = true;
};
let boltTimer = 0;
const state: any = {
camera: cam,
raycaster,
mouse,
nodeMeshes,
animationId: 0,
isPaused: false,
isDragging: false,
userControlled: false,
dragStart: { x: 0, y: 0 },
resumeTimer: 0 as any,
};
sceneRef.current = state;
const onDown = (e: MouseEvent) => {
state.isDragging = true;
state.dragStart = { x: e.clientX, y: e.clientY };
state.userControlled = true;
state.isPaused = true;
clearTimeout(state.resumeTimer);
};
const onMove = (e: MouseEvent) => {
if (!state.isDragging) return;
const dx = e.clientX - state.dragStart.x;
const dy = e.clientY - state.dragStart.y;
group.rotation.y += dx * 0.004;
group.rotation.x += dy * 0.004;
state.dragStart = { x: e.clientX, y: e.clientY };
};
const onUp = () => {
if (!state.isDragging) return;
state.isDragging = false;
state.resumeTimer = setTimeout(() => {
state.userControlled = false;
state.isPaused = false;
}, 3000);
};
canvas.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
const animate = () => {
state.animationId = requestAnimationFrame(animate);
const hovered = hoveredRef.current;
const shouldPause = state.isPaused || state.userControlled || hovered !== null;
if (!shouldPause) {
group.rotation.y += 0.00012;
}
// Update lightning bolts
const dt = 0.016;
boltTimer += dt;
if (boltTimer > 2.5 + Math.random() * 3.5) {
boltTimer = 0;
fireBolt();
}
bolts.forEach(bolt => {
if (!bolt.alive) return;
bolt.progress += bolt.speed * dt;
if (bolt.progress >= 1.3) {
bolt.alive = false;
bolt.line.visible = false;
bolt.glowPts.visible = false;
return;
}
const cAttr = bolt.line.geometry.attributes.color as THREE.BufferAttribute;
const gcAttr = bolt.glowPts.geometry.attributes.color as THREE.BufferAttribute;
for (let s = 0; s <= BOLT_SEGS; s++) {
const t = s / BOLT_SEGS;
const dist = Math.abs(t - bolt.progress);
const headW = 0.35;
const bright = Math.max(0, 1 - dist / headW);
const fade = Math.pow(bright, 1.5);
const r = 0.35 + fade * 0.2;
const g = 0.15 + fade * 0.15;
const b2 = 0.55 + fade * 0.4;
cAttr.setXYZ(s, r * fade, g * fade, b2 * fade);
gcAttr.setXYZ(s, r * fade * 0.5, g * fade * 0.5, b2 * fade * 0.5);
}
cAttr.needsUpdate = true;
gcAttr.needsUpdate = true;
});
renderer.render(scene, cam);
};
animate();
const onResize = () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
cam.aspect = w / h;
cam.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(state.animationId);
clearTimeout(state.resumeTimer);
canvas.removeEventListener('mousedown', onDown);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('resize', onResize);
renderer.dispose();
};
}, [nodes]);
const netInfo = networkGraphStore.networkInfo;
return (
<S.Wrapper>
{networkGraphStore.loading && (
<S.Loading>
<Loader2 size={24} style={{ animation: 'spin 1s linear infinite' }} />
Loading network data...
</S.Loading>
)}
<S.Canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onClick={handleClick}
style={{
cursor: hoveredNode ? 'pointer' : 'grab',
}}
/>
<S.TopBar>
<S.BackBtn onClick={() => appView.goTo('/')}>
<ArrowLeft size={16} />
</S.BackBtn>
<S.Title>Explorer</S.Title>
<S.Stats>
{netInfo && (
<>
<span>
Nodes
<S.StatVal>{netInfo.numNodes.toLocaleString()}</S.StatVal>
</span>
<span>
Channels
<S.StatVal>{netInfo.numChannels.toLocaleString()}</S.StatVal>
</span>
<span>
Capacity
<S.StatVal>
{formatSats(parseInt(netInfo.totalNetworkCapacity, 10) || 0)}
</S.StatVal>
</span>
</>
)}
</S.Stats>
</S.TopBar>
{hoveredNode && !selectedNode && (
<S.Tooltip
style={{
left: mousePos.x + 14,
top: mousePos.y + 14,
}}
>
<S.TtAlias>{hoveredNode.alias}</S.TtAlias>
<S.TtRow>
Capacity
<span>{formatSats(hoveredNode.capacity)}</span>
</S.TtRow>
<S.TtRow>
Channels
<span>{hoveredNode.channelCount.toLocaleString()}</span>
</S.TtRow>
</S.Tooltip>
)}
{selectedNode && (
<S.DetailPanel>
<S.DpClose onClick={() => setSelectedNode(null)}>
<X size={16} />
</S.DpClose>
<S.DpAlias>{selectedNode.alias}</S.DpAlias>
<S.DpPubkey>
{selectedNode.id.substring(0, 24)}...
<S.DpCopyBtn onClick={() => handleCopyPk(selectedNode.id)}>
{copiedPk ? <Check size={12} /> : <Copy size={12} />}
</S.DpCopyBtn>
</S.DpPubkey>
<S.DpStats>
<S.DpStat>
<S.DpStatLabel>Capacity</S.DpStatLabel>
<S.DpStatVal>{formatSats(selectedNode.capacity)}</S.DpStatVal>
</S.DpStat>
<S.DpStat>
<S.DpStatLabel>Channels</S.DpStatLabel>
<S.DpStatVal>{selectedNode.channelCount.toLocaleString()}</S.DpStatVal>
</S.DpStat>
</S.DpStats>
<S.DpActions>
<S.DpActionBtn
onClick={() =>
window.open(
`https://mempool.space/lightning/node/${selectedNode.id}`,
'_blank',
)
}
>
<ExternalLink size={13} /> View on Mempool
</S.DpActionBtn>
<S.DpActionBtn onClick={() => handleCopyPk(selectedNode.id)}>
{copiedPk ? <Check size={13} /> : <Copy size={13} />}
{copiedPk ? 'Copied!' : 'Copy Node ID'}
</S.DpActionBtn>
</S.DpActions>
</S.DetailPanel>
)}
{!bannerDismissed && !networkGraphStore.loading && (
<S.Banner>
<S.BannerText>
<strong>Ready to join the network?</strong> Connect your Lightning node to
manage channels and liquidity.
</S.BannerText>
<S.BannerBtn variant="primary" onClick={() => appView.goTo('/connect-node')}>
<Link2 size={13} /> Connect
</S.BannerBtn>
<S.BannerBtn variant="secondary" onClick={() => appView.goTo('/get-node')}>
<Server size={13} /> Get a Node
</S.BannerBtn>
<S.BannerClose onClick={() => setBannerDismissed(true)}>
<X size={14} />
</S.BannerClose>
</S.Banner>
)}
</S.Wrapper>
);
};
export default observer(ExploreNetworkPage);

View file

@ -0,0 +1,433 @@
import React, { useState } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { keyframes } from '@emotion/react';
import { useStore } from 'store';
import {
ArrowLeft,
ExternalLink,
Sparkles,
Copy,
Check,
ChevronRight,
} from 'lucide-react';
const fadeUp = keyframes`
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
`;
const S = {
Wrapper: styled.div`
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
padding: 60px 24px 60px;
background: #030508;
color: #e5e7eb;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow-y: auto;
`,
Back: styled.button`
position: fixed;
top: 20px;
left: 20px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 8px;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #ffffff;
}
`,
Header: styled.div`
text-align: center;
margin-bottom: 48px;
animation: ${fadeUp} 0.5s ease both;
`,
PageLabel: styled.div`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.2em;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 12px;
`,
Title: styled.h1`
font-size: 36px;
font-weight: 600;
letter-spacing: -0.03em;
color: #ffffff;
margin: 0 0 10px;
`,
Subtitle: styled.p`
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
margin: 0;
max-width: 420px;
line-height: 1.6;
`,
Section: styled.div`
width: 100%;
max-width: 600px;
margin-bottom: 40px;
animation: ${fadeUp} 0.5s ease both;
animation-delay: 0.1s;
`,
SectionLabel: styled.div`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.15em;
color: rgba(255, 255, 255, 0.25);
margin-bottom: 16px;
padding-left: 4px;
`,
ProviderGrid: styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
@media (max-width: 620px) {
grid-template-columns: 1fr;
}
`,
ProviderTile: styled.a`
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 22px 18px;
background: rgba(18, 20, 28, 0.92);
border: none;
border-radius: 16px;
text-decoration: none;
color: #e5e7eb;
cursor: pointer;
transition: background 0.2s ease;
min-height: 140px;
&:hover {
background: rgba(28, 31, 42, 0.95);
}
`,
ProviderLogo: styled.img`
width: 36px;
height: 36px;
border-radius: 8px;
margin-bottom: 16px;
object-fit: cover;
`,
ProviderName: styled.div`
font-size: 15px;
font-weight: 600;
color: #ffffff;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 5px;
`,
ProviderDesc: styled.div`
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.45;
`,
ProviderArrow: styled.div`
position: absolute;
top: 22px;
right: 18px;
color: rgba(255, 255, 255, 0.12);
`,
AgentCard: styled.div`
background: rgba(18, 20, 28, 0.92);
border-radius: 16px;
padding: 24px;
`,
AgentCardHeader: styled.div`
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
`,
AgentCardIcon: styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(139, 92, 246, 0.12);
color: rgba(167, 139, 250, 0.9);
`,
AgentCardTitle: styled.div`
font-size: 15px;
font-weight: 600;
color: #ffffff;
`,
AgentCardDesc: styled.p`
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.55;
margin: 0 0 16px;
`,
PromptBox: styled.div`
position: relative;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 12px;
padding: 16px 48px 16px 16px;
margin-bottom: 14px;
`,
PromptText: styled.div`
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.5);
white-space: pre-wrap;
word-break: break-word;
`,
CopyBtn: styled.button<{ copied?: boolean }>`
position: absolute;
top: 12px;
right: 12px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: ${p =>
p.copied ? 'rgba(52, 211, 153, 0.15)' : 'rgba(255, 255, 255, 0.06)'};
color: ${p => (p.copied ? 'rgba(52, 211, 153, 0.9)' : 'rgba(255, 255, 255, 0.35)')};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${p =>
p.copied ? 'rgba(52, 211, 153, 0.2)' : 'rgba(255, 255, 255, 0.1)'};
color: ${p => (p.copied ? 'rgba(52, 211, 153, 1)' : 'rgba(255, 255, 255, 0.6)')};
}
`,
AgentSteps: styled.div`
display: flex;
gap: 20px;
margin-top: 2px;
@media (max-width: 500px) {
flex-direction: column;
gap: 6px;
}
`,
AgentStep: styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
`,
StepNum: styled.span`
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
`,
DocLink: styled.a`
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
text-decoration: none;
margin-top: 14px;
transition: color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.7);
}
`,
Footer: styled.div`
text-align: center;
animation: ${fadeUp} 0.5s ease both;
animation-delay: 0.2s;
`,
FooterNote: styled.p`
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
max-width: 420px;
line-height: 1.6;
margin: 0;
`,
FooterLink: styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0;
&:hover {
color: #ffffff;
}
`,
};
const providers = [
{
name: 'Voltage',
desc: 'Cloud Lightning nodes. Pay monthly, no hardware.',
url: 'https://voltage.cloud',
logo: '/icons/voltage.png',
},
{
name: 'Umbrel',
desc: 'Personal home server. App store with Lightning.',
url: 'https://umbrel.com',
logo: '/icons/umbrel.png',
},
{
name: 'Start9',
desc: 'Sovereign computing. Run your own server.',
url: 'https://start9.com',
logo: '/icons/start9.png',
},
];
const AGENT_PROMPT = `Download and install Lightning Terminal (litd) from github.com/lightninglabs/lightning-terminal/releases for my operating system. Extract the release, then start litd with the following flags:
--lnd-mode=integrated
--network=mainnet
--uipassword=<ask me to choose a password>
Use Neutrino as the Bitcoin backend if I don't have a synced Bitcoin Core node. Once litd is running and healthy, generate a Lightning Node Connect pairing phrase by running:
litcli sessions add --type admin
Give me the pairing phrase so I can connect my node.
Refer to the docs at docs.lightning.engineering/lightning-network-tools/lightning-terminal if you need more details.`;
const GetNodePage: React.FC = () => {
const store = useStore();
const [copied, setCopied] = useState(false);
if (!store.initialized) return null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(AGENT_PROMPT);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
}
};
return (
<S.Wrapper>
<S.Back onClick={() => store.appView.goTo('/')}>
<ArrowLeft size={16} />
</S.Back>
<S.Header>
<S.PageLabel>Get Started</S.PageLabel>
<S.Title>Get a Node</S.Title>
<S.Subtitle>
Choose a hosted provider for the easiest setup, or spin up your own with an AI
agent.
</S.Subtitle>
</S.Header>
<S.Section>
<S.SectionLabel>Providers</S.SectionLabel>
<S.ProviderGrid>
{providers.map(p => (
<S.ProviderTile key={p.name} href={p.url} target="_blank" rel="noopener">
<S.ProviderArrow>
<ExternalLink size={13} />
</S.ProviderArrow>
<S.ProviderLogo src={p.logo} alt={p.name} />
<S.ProviderName>{p.name}</S.ProviderName>
<S.ProviderDesc>{p.desc}</S.ProviderDesc>
</S.ProviderTile>
))}
</S.ProviderGrid>
</S.Section>
<S.Section style={{ animationDelay: '0.15s' }}>
<S.SectionLabel>Run Your Own</S.SectionLabel>
<S.AgentCard>
<S.AgentCardHeader>
<S.AgentCardIcon>
<Sparkles size={16} />
</S.AgentCardIcon>
<S.AgentCardTitle>Set up with your AI agent</S.AgentCardTitle>
</S.AgentCardHeader>
<S.AgentCardDesc>
Copy this prompt and paste it into your AI coding agent (Cursor, Windsurf,
Claude Code, etc.) to set up a Lightning node on your machine.
</S.AgentCardDesc>
<S.PromptBox>
<S.PromptText>{AGENT_PROMPT}</S.PromptText>
<S.CopyBtn onClick={handleCopy} copied={copied} title="Copy prompt">
{copied ? <Check size={14} /> : <Copy size={14} />}
</S.CopyBtn>
</S.PromptBox>
<S.AgentSteps>
<S.AgentStep>
<S.StepNum>1</S.StepNum>
Copy prompt
</S.AgentStep>
<S.AgentStep>
<S.StepNum>2</S.StepNum>
Paste into your agent
</S.AgentStep>
<S.AgentStep>
<S.StepNum>3</S.StepNum>
Follow along as it installs
</S.AgentStep>
</S.AgentSteps>
<S.DocLink
href="https://docs.lightning.engineering/lightning-network-tools/lightning-terminal"
target="_blank"
rel="noopener noreferrer"
>
Full documentation <ChevronRight size={11} style={{ verticalAlign: -1 }} />
</S.DocLink>
</S.AgentCard>
</S.Section>
<S.Footer>
<S.FooterNote>
Once your node is running, generate a pairing phrase with{' '}
<code style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>
litcli sessions add
</code>{' '}
and come back to{' '}
<S.FooterLink onClick={() => store.appView.goTo('/connect-node')}>
Connect Your Node
</S.FooterLink>
.
</S.FooterNote>
</S.Footer>
</S.Wrapper>
);
};
export default observer(GetNodePage);

View file

@ -0,0 +1,546 @@
import React, { useRef, useEffect, useState } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { keyframes } from '@emotion/react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - three.js types require newer TS
import * as THREE from 'three';
import { useStore } from 'store';
import {
Globe,
Link2,
Server,
HelpCircle,
X,
Zap,
Shield,
ArrowLeftRight,
ChevronRight,
} from 'lucide-react';
const fadeUp = keyframes`
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
`;
const S = {
Wrapper: styled.div`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`,
Canvas: styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
`,
Scrim: styled.div`
position: absolute;
inset: 0;
z-index: 1;
background: radial-gradient(
ellipse at center,
rgba(3, 5, 8, 0.45) 0%,
rgba(3, 5, 8, 0.7) 100%
);
`,
Content: styled.div`
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 40px 24px;
gap: 0;
`,
Header: styled.div`
text-align: center;
margin-bottom: 48px;
animation: ${fadeUp} 0.6s ease both;
`,
WelcomeLabel: styled.div`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.2em;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 12px;
`,
Title: styled.h1`
font-size: 42px;
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.05;
color: #ffffff;
margin: 0 0 12px;
@media (max-width: 768px) {
font-size: 32px;
}
`,
Tagline: styled.p`
font-size: 15px;
color: rgba(255, 255, 255, 0.4);
margin: 0;
line-height: 1.5;
font-weight: 400;
`,
TileGrid: styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
max-width: 720px;
width: 100%;
animation: ${fadeUp} 0.6s ease both;
animation-delay: 0.1s;
@media (max-width: 680px) {
grid-template-columns: 1fr;
max-width: 360px;
}
`,
Tile: styled.button`
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 24px 20px;
background: rgba(18, 20, 28, 0.92);
border: none;
border-radius: 16px;
cursor: pointer;
transition: background 0.2s ease;
text-align: left;
color: #e5e7eb;
font-family: inherit;
min-height: 160px;
&:hover {
background: rgba(28, 31, 42, 0.95);
}
&:focus {
outline: none;
}
&:active {
background: rgba(35, 38, 50, 0.95);
}
`,
TileIcon: styled.div`
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
`,
TileTitle: styled.div`
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 6px;
color: #ffffff;
`,
TileDesc: styled.div`
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.45;
flex: 1;
`,
TileArrow: styled.div`
position: absolute;
top: 24px;
right: 20px;
color: rgba(255, 255, 255, 0.15);
transition: color 0.2s ease;
button:hover & {
color: rgba(255, 255, 255, 0.4);
}
`,
Footer: styled.div`
margin-top: 40px;
animation: ${fadeUp} 0.6s ease both;
animation-delay: 0.2s;
`,
LearnLink: styled.button`
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.25);
font-size: 13px;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
transition: color 0.15s ease;
font-weight: 400;
&:hover {
color: rgba(255, 255, 255, 0.5);
}
`,
InfoOverlay: styled.div`
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 5, 8, 0.8);
backdrop-filter: blur(20px);
padding: 24px;
`,
InfoPanel: styled.div`
max-width: 480px;
width: 100%;
animation: ${fadeUp} 0.3s ease both;
position: relative;
`,
InfoClose: styled.button`
position: absolute;
top: 0;
right: 0;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 10px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
`,
InfoTitle: styled.h2`
font-size: 28px;
font-weight: 600;
letter-spacing: -0.03em;
color: #ffffff;
margin: 0 0 16px;
`,
InfoBody: styled.div`
font-size: 15px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.7;
text-align: left;
`,
InfoFeatures: styled.div`
display: flex;
flex-direction: column;
gap: 16px;
margin: 28px 0 32px;
`,
InfoFeature: styled.div`
display: flex;
align-items: flex-start;
gap: 14px;
text-align: left;
`,
InfoFeatureIcon: styled.div`
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
`,
InfoFeatureText: styled.div`
flex: 1;
`,
InfoFeatureTitle: styled.div`
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 3px;
`,
InfoFeatureDesc: styled.div`
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.5;
`,
InfoCTA: styled.button`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 48px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.12);
}
`,
};
const WelcomePage: React.FC = () => {
const store = useStore();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const scene = new THREE.Scene();
const cam = new THREE.PerspectiveCamera(
60,
canvas.clientWidth / canvas.clientHeight,
0.1,
500,
);
cam.position.set(0, 0, 22);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x030508, 1);
const group = new THREE.Group();
scene.add(group);
// Background stars
const starCount = 2400;
const sPos = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const t = Math.random() * Math.PI * 2;
const p = Math.acos(2 * Math.random() - 1);
const r = 8 + Math.random() * 90;
sPos[i * 3] = r * Math.sin(p) * Math.cos(t);
sPos[i * 3 + 1] = r * Math.sin(p) * Math.sin(t);
sPos[i * 3 + 2] = r * Math.cos(p);
}
const sGeo = new THREE.BufferGeometry();
sGeo.setAttribute('position', new THREE.BufferAttribute(sPos, 3));
const sMat = new THREE.PointsMaterial({
color: 0x8b8fbf,
size: 0.1,
transparent: true,
opacity: 0.45,
sizeAttenuation: true,
});
group.add(new THREE.Points(sGeo, sMat));
// Glowing "network" nodes
const nodeCount = 80;
const colors = [0x8b5cf6, 0x6366f1, 0xa78bfa, 0x7c3aed];
for (let i = 0; i < nodeCount; i++) {
const t = Math.random() * Math.PI * 2;
const p = Math.acos(2 * Math.random() - 1);
const r = 5 + Math.random() * 30;
const x = r * Math.sin(p) * Math.cos(t);
const y = r * Math.sin(p) * Math.sin(t);
const z = r * Math.cos(p);
const sz = 0.03 + Math.random() * 0.08;
const col = colors[Math.floor(Math.random() * colors.length)];
const core = new THREE.Mesh(
new THREE.SphereGeometry(sz, 8, 8),
new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.7,
}),
);
core.position.set(x, y, z);
group.add(core);
const glow = new THREE.Mesh(
new THREE.SphereGeometry(sz * 2.5, 8, 8),
new THREE.MeshBasicMaterial({
color: col,
transparent: true,
opacity: 0.04,
}),
);
glow.position.set(x, y, z);
group.add(glow);
}
let animId = 0;
const animate = () => {
animId = requestAnimationFrame(animate);
group.rotation.y += 0.00015;
group.rotation.x += 0.00005;
renderer.render(scene, cam);
};
animate();
const onResize = () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
cam.aspect = w / h;
cam.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', onResize);
renderer.dispose();
};
}, []);
if (!store.initialized) return null;
return (
<S.Wrapper>
<S.Canvas ref={canvasRef} />
<S.Scrim />
<S.Content>
<S.Header>
<S.WelcomeLabel>Welcome to</S.WelcomeLabel>
<S.Title>The Lightning Network</S.Title>
<S.Tagline>Instant, low-cost, global payments built on Bitcoin.</S.Tagline>
</S.Header>
<S.TileGrid>
<S.Tile onClick={() => store.appView.goTo('/explore-network')}>
<S.TileArrow>
<ChevronRight size={16} />
</S.TileArrow>
<S.TileIcon>
<Globe size={20} />
</S.TileIcon>
<S.TileTitle>Explore</S.TileTitle>
<S.TileDesc>
Browse nodes, capacity, and topology no node required
</S.TileDesc>
</S.Tile>
<S.Tile onClick={() => store.appView.goTo('/connect-node')}>
<S.TileArrow>
<ChevronRight size={16} />
</S.TileArrow>
<S.TileIcon>
<Link2 size={20} />
</S.TileIcon>
<S.TileTitle>Connect</S.TileTitle>
<S.TileDesc>Securely pair with your Lightning node via LNC</S.TileDesc>
</S.Tile>
<S.Tile onClick={() => store.appView.goTo('/get-node')}>
<S.TileArrow>
<ChevronRight size={16} />
</S.TileArrow>
<S.TileIcon>
<Server size={20} />
</S.TileIcon>
<S.TileTitle>Get a Node</S.TileTitle>
<S.TileDesc>
Hosted providers or self-host get started in minutes
</S.TileDesc>
</S.Tile>
</S.TileGrid>
<S.Footer>
<S.LearnLink onClick={() => setShowInfo(true)}>
<HelpCircle size={14} />
What is the Lightning Network?
</S.LearnLink>
</S.Footer>
</S.Content>
{showInfo && (
<S.InfoOverlay onClick={() => setShowInfo(false)}>
<S.InfoPanel onClick={e => e.stopPropagation()}>
<S.InfoClose onClick={() => setShowInfo(false)}>
<X size={16} />
</S.InfoClose>
<S.InfoTitle>The Lightning Network</S.InfoTitle>
<S.InfoBody>
A layer-2 payment protocol on top of Bitcoin. Near-instant transactions with
extremely low fees through payment channels between nodes.
</S.InfoBody>
<S.InfoFeatures>
<S.InfoFeature>
<S.InfoFeatureIcon>
<Zap size={18} />
</S.InfoFeatureIcon>
<S.InfoFeatureText>
<S.InfoFeatureTitle>Instant Payments</S.InfoFeatureTitle>
<S.InfoFeatureDesc>
Transactions settle in milliseconds, not minutes.
</S.InfoFeatureDesc>
</S.InfoFeatureText>
</S.InfoFeature>
<S.InfoFeature>
<S.InfoFeatureIcon>
<ArrowLeftRight size={18} />
</S.InfoFeatureIcon>
<S.InfoFeatureText>
<S.InfoFeatureTitle>Negligible Fees</S.InfoFeatureTitle>
<S.InfoFeatureDesc>
Send any amount for fractions of a cent. No middlemen.
</S.InfoFeatureDesc>
</S.InfoFeatureText>
</S.InfoFeature>
<S.InfoFeature>
<S.InfoFeatureIcon>
<Shield size={18} />
</S.InfoFeatureIcon>
<S.InfoFeatureText>
<S.InfoFeatureTitle>Secured by Bitcoin</S.InfoFeatureTitle>
<S.InfoFeatureDesc>
All channels backed by real on-chain Bitcoin.
</S.InfoFeatureDesc>
</S.InfoFeatureText>
</S.InfoFeature>
<S.InfoFeature>
<S.InfoFeatureIcon>
<Globe size={18} />
</S.InfoFeatureIcon>
<S.InfoFeatureText>
<S.InfoFeatureTitle>Global Network</S.InfoFeatureTitle>
<S.InfoFeatureDesc>
Thousands of nodes routing payments across an open, permissionless
network.
</S.InfoFeatureDesc>
</S.InfoFeatureText>
</S.InfoFeature>
</S.InfoFeatures>
<S.InfoCTA onClick={() => setShowInfo(false)}>Got it</S.InfoCTA>
</S.InfoPanel>
</S.InfoOverlay>
)}
</S.Wrapper>
);
};
export default observer(WelcomePage);

View file

@ -41,60 +41,61 @@ interface IconProps {
}
const Icon = styled.span<IconProps>`
display: inline-block;
padding: 6px;
transition: all 0.3s;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
transition: all 0.15s ease;
${props =>
props.onClick &&
`
border-radius: 36px;
border-radius: 6px;
cursor: pointer;
&:hover {
color: ${props.theme.colors.blue};
background-color: ${props.theme.colors.offWhite};
background-color: rgba(255, 255, 255, 0.08);
}
`}
${props =>
props.disabled &&
`
color: ${props.theme.colors.lightBlue};
opacity: 0.3;
`}
${props =>
props.size === 'x-small' &&
`
width: 16px;
height: 16px;
width: 14px;
height: 14px;
`}
${props =>
props.size === 'small' &&
`
width: 24px;
height: 24px;
width: 18px;
height: 18px;
`}
${props =>
(props.size === 'medium' || !props.size) &&
`
width: 30px;
height: 30px;
width: 22px;
height: 22px;
`}
${props =>
props.size === 'large' &&
`
width: 36px;
height: 36px;
width: 28px;
height: 28px;
`}
${props =>
props.size === 'x-large' &&
`
width: 48px;
height: 48px;
width: 36px;
height: 36px;
`}
`;

View file

@ -7,52 +7,54 @@ import Chevrons from 'assets/icons/chevrons.svg';
export const Background = styled.div<{ gradient?: boolean }>`
height: 100%;
color: ${props => props.theme.colors.white};
color: ${props => props.theme.colors.offWhite};
background: ${props =>
props.gradient ? props.theme.colors.gradient : props.theme.colors.blue};
font-family: ${props => props.theme.fonts.open.regular};
font-size: ${props => props.theme.sizes.m};
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: ${props => props.theme.sizes.s};
font-weight: 400;
letter-spacing: -0.01em;
`;
export const Pill = styled.span`
display: inline-block;
width: 40px;
height: 40px;
padding: 5px;
margin-right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 8px;
text-align: center;
background-color: ${props => props.theme.colors.overlay};
border-radius: 40px;
background-color: rgba(255, 255, 255, 0.06);
border-radius: 8px;
`;
export const Badge = styled.span<{ muted?: boolean }>`
display: inline-block;
margin-left: 10px;
font-family: ${props => props.theme.fonts.open.light};
font-size: ${props => props.theme.sizes.xxs};
font-weight: 700;
color: ${props => props.theme.colors.pink};
border: 1px solid ${props => props.theme.colors.pink};
margin-left: 8px;
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 600;
color: ${props => props.theme.colors.iris};
background: rgba(99, 102, 241, 0.12);
border-radius: 4px;
padding: 3px 5px 5px;
text-transform: lowercase;
line-height: 1;
letter-spacing: 1.8px;
padding: 2px 6px;
text-transform: uppercase;
line-height: 1.2;
letter-spacing: 0.5px;
${props =>
props.muted &&
`
color: ${props.theme.colors.gray};
border: 1px solid ${props.theme.colors.gray};
background: rgba(107, 114, 128, 0.12);
`}
`;
export const Section = styled.section`
padding: 15px;
background-color: ${props => props.theme.colors.overlay};
border: 1px solid ${props => props.theme.colors.blue};
border-radius: 5px;
padding: 16px;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
`;
export const SummaryItem = styled.div<{ strong?: boolean }>`
@ -89,21 +91,24 @@ interface ButtonProps {
}
export const Button = styled.button<ButtonProps>`
font-family: ${props => props.theme.fonts.work.medium};
font-size: ${props => props.theme.sizes.s};
min-width: ${props => (props.compact ? '0' : '120px')};
height: ${props => (props.compact ? 'auto' : '44px')};
padding-left: 15px;
padding-right: 15px;
font-family: 'Inter', sans-serif;
font-size: ${props => props.theme.sizes.xs};
font-weight: 500;
min-width: ${props => (props.compact ? '0' : '80px')};
height: ${props => (props.compact ? 'auto' : '36px')};
padding: 0 16px;
text-align: center;
color: ${props => props.theme.colors.offWhite};
background-color: ${props => props.theme.colors.blue};
border: 1px solid ${props => props.theme.colors.offWhite};
border-radius: 22px;
background-color: ${props => props.theme.colors.lightBlue};
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
transition: all 0.15s ease;
letter-spacing: -0.01em;
&:hover {
color: ${props => props.theme.colors.blue};
background-color: ${props => props.theme.colors.white};
color: ${props => props.theme.colors.white};
background-color: ${props => props.theme.colors.paleBlue};
border-color: rgba(255, 255, 255, 0.2);
cursor: pointer;
}
@ -116,19 +121,21 @@ export const Button = styled.button<ButtonProps>`
props.ghost &&
`
background-color: transparent;
border-color: transparent;
&:hover {
color: ${props.theme.colors.offWhite};
text-decoration: underline;
background-color: transparent;
color: ${props.theme.colors.white};
background-color: rgba(255, 255, 255, 0.06);
border-color: transparent;
}
`}
${props =>
props.disabled &&
`
opacity: 0.5;
opacity: 0.4;
&:hover {
cursor: not-allowed;
background-color: ${props.theme.colors.lightBlue};
}
`}
@ -142,9 +149,10 @@ export const Button = styled.button<ButtonProps>`
props.danger &&
`
border: 1px solid ${props.theme.colors.pink};
color: ${props.theme.colors.pink};
background-color: rgba(244, 63, 94, 0.1);
&:hover {
color: ${props.theme.colors.blue};
text-decoration: none;
color: ${props.theme.colors.white};
background-color: ${props.theme.colors.pink};
}
`}
@ -152,16 +160,16 @@ export const Button = styled.button<ButtonProps>`
${props =>
props.primary &&
`
border: 1px solid ${props.theme.colors.green};
border: none;
color: ${props.theme.colors.white};
background-color: ${props.theme.colors.green};
&:hover {
color: ${props.theme.colors.blue};
text-decoration: none;
background-color: ${props.theme.colors.green};
background-color: #0ea572;
}
`}
svg {
margin: 0 5px 0 0;
margin: 0 6px 0 0;
&:hover {
background-color: transparent;
@ -170,21 +178,23 @@ export const Button = styled.button<ButtonProps>`
`;
export const Input = styled.input`
font-family: ${props => props.theme.fonts.work.light};
font-weight: 300;
font-size: ${props => props.theme.sizes.xxl};
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: ${props => props.theme.sizes.xl};
color: ${props => props.theme.colors.offWhite};
background-color: transparent;
border-width: 0;
border-bottom: 3px solid ${props => props.theme.colors.offWhite};
padding: 5px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 10px 14px;
text-align: center;
transition: border-color 0.15s ease;
letter-spacing: -0.02em;
&:active,
&:focus {
outline: none;
background-color: ${props => props.theme.colors.overlay};
border-bottom-color: ${props => props.theme.colors.white};
border-color: ${props => props.theme.colors.iris};
background-color: rgba(255, 255, 255, 0.06);
}
&::placeholder {
@ -193,20 +203,21 @@ export const Input = styled.input`
`;
export const TextArea = styled.textarea`
font-family: ${props => props.theme.fonts.work.light};
font-weight: 300;
font-size: ${props => props.theme.sizes.m};
font-family: 'Inter', sans-serif;
font-weight: 400;
font-size: ${props => props.theme.sizes.s};
color: ${props => props.theme.colors.offWhite};
background-color: ${props => props.theme.colors.overlay};
border-width: 0;
border-bottom: 3px solid ${props => props.theme.colors.offWhite};
padding: 5px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 10px 14px;
transition: border-color 0.15s ease;
&:active,
&:focus {
outline: none;
background-color: ${props => props.theme.colors.overlay};
border-bottom-color: ${props => props.theme.colors.white};
border-color: ${props => props.theme.colors.iris};
background-color: rgba(255, 255, 255, 0.06);
}
&::placeholder {
@ -365,21 +376,17 @@ export const Scrollable = styled.div`
height: 0px;
overflow: auto;
// use consistent scrollbars across different platforms
&::-webkit-scrollbar {
width: 8px;
background-color: rgba(0, 0, 0, 0);
border-radius: 10px;
width: 6px;
}
&::-webkit-scrollbar:hover {
background-color: rgba(255, 255, 255, 0.05);
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb:vertical {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:vertical:active {
background-color: rgba(255, 255, 255, 0.6);
border-radius: 10px;
&::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.15);
}
`;

View file

@ -5,54 +5,57 @@ interface HeaderProps {
}
export const HeaderOne = styled.h1`
font-family: ${props => props.theme.fonts.work.light};
font-weight: 300;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: ${props => props.theme.sizes.xxl};
line-height: 52px;
line-height: 1.2;
letter-spacing: -0.03em;
`;
export const HeaderTwo = styled.h2`
font-family: ${props => props.theme.fonts.open.regular};
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: ${props => props.theme.sizes.m};
line-height: 26px;
line-height: 1.4;
`;
export const HeaderThree = styled.h3`
font-family: ${props => props.theme.fonts.open.light};
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: ${props => props.theme.sizes.l};
line-height: 30px;
letter-spacing: 2.7px;
text-transform: uppercase;
line-height: 1.3;
letter-spacing: -0.02em;
`;
export const HeaderFour = styled.h4<HeaderProps>`
font-family: ${props => props.theme.fonts.open.semiBold};
font-size: ${props => props.theme.sizes.xs};
line-height: 20px;
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: ${props => props.theme.sizes.xxs};
line-height: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${props => props.theme.colors.gray};
margin-bottom: ${props => (props.marginless ? '0' : '0.5rem')};
`;
export const HeaderFive = styled.h5`
font-family: ${props => props.theme.fonts.open.bold};
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: ${props => props.theme.sizes.m};
`;
export const Small = styled.span`
font-size: ${props => props.theme.sizes.xs};
line-height: 20px;
line-height: 18px;
`;
export const Jumbo = styled.span`
font-size: ${props => props.theme.sizes.xl};
line-height: 38px;
line-height: 32px;
font-weight: 600;
letter-spacing: -0.02em;
`;
//
// v2 Text Styles
//
interface TextProps {
bold?: boolean;
semiBold?: boolean;
@ -64,15 +67,9 @@ interface TextProps {
}
const BaseText = styled.span<TextProps>`
// On larger devices, make bold elements bold instead of semi-bold
font-family: ${props =>
props.bold
? props.theme.fonts.open.bold
: props.semiBold
? props.theme.fonts.open.semiBold
: props.theme.fonts.open.regular};
font-family: 'Inter', sans-serif;
font-weight: ${props => (props.bold ? 700 : props.semiBold ? 600 : 400)};
// The text-align property is ignored on mobile
${props => props.muted && `color: ${props.theme.colors.gray};`}
${props => props.space && `margin-bottom: ${props.space}px;`}
text-align: ${props => (props.center ? 'center' : 'left')};
@ -81,16 +78,18 @@ const BaseText = styled.span<TextProps>`
const BaseBlock = BaseText.withComponent('div');
export const DisplayLarge = styled(BaseBlock)`
font-size: 40px;
line-height: 48px;
font-size: 32px;
line-height: 40px;
letter-spacing: -0.03em;
`;
export const Display = styled(BaseBlock)`
font-size: 32px;
line-height: 40px;
font-size: 24px;
line-height: 32px;
letter-spacing: -0.02em;
`;
export const Paragraph = styled(BaseBlock)`
font-size: 16px;
line-height: 24px;
font-size: 14px;
line-height: 22px;
`;

View file

@ -0,0 +1,655 @@
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';
import { observer } from 'mobx-react-lite';
import { useStore } from 'store';
import {
X,
Plus,
Server,
ExternalLink,
Zap,
HardDrive,
Cloud,
Globe,
Loader2,
AlertCircle,
Link,
} from 'lucide-react';
const PROVIDERS = [
{
name: 'Voltage',
desc: 'Managed cloud Lightning nodes. Easiest setup.',
url: 'https://voltage.cloud',
icon: Cloud,
tag: 'Recommended',
},
{
name: 'Umbrel',
desc: 'Run a node at home on a Raspberry Pi or old laptop.',
url: 'https://umbrel.com',
icon: HardDrive,
},
{
name: 'Start9',
desc: 'Sovereign home server with a full app ecosystem.',
url: 'https://start9.com',
icon: Server,
},
{
name: 'Nodl',
desc: 'Plug-and-play dedicated hardware node.',
url: 'https://www.nodl.it',
icon: HardDrive,
},
{
name: 'Luna Node',
desc: 'VPS hosting for self-managed Lightning nodes.',
url: 'https://www.lunanode.com',
icon: Globe,
},
];
export interface SavedNode {
id: string;
alias: string;
type: 'lnc' | 'manual';
rpcServer?: string;
macaroonHex?: string;
tlsCertPath?: string;
}
interface Props {
onClose: () => void;
onSave?: (node: SavedNode) => void;
}
const AddNodeModal: React.FC<Props> = ({ onClose, onSave }) => {
const { nodeConnectionStore } = useStore();
const [view, setView] = useState<'choose' | 'lnc' | 'manual'>('choose');
const [label, setLabel] = useState('');
const [pairingPhrase, setPairingPhrase] = useState('');
const [lncPassword, setLncPassword] = useState('');
const [rpcServer, setRpcServer] = useState('');
const [macaroonHex, setMacaroonHex] = useState('');
const [tlsCertPath, setTlsCertPath] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLncConnect = async () => {
if (!pairingPhrase.trim() || !lncPassword.trim()) return;
setLoading(true);
setError('');
try {
await nodeConnectionStore.addLncNode(
label.trim() || 'Remote Node',
pairingPhrase,
lncPassword,
);
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to connect via LNC');
} finally {
setLoading(false);
}
};
const handleManualSave = () => {
if (!rpcServer.trim() || !macaroonHex.trim()) return;
const node: SavedNode = {
id: Date.now().toString(36),
alias: label.trim() || rpcServer.split('.')[0],
type: 'manual',
rpcServer: rpcServer.trim(),
macaroonHex: macaroonHex.trim(),
tlsCertPath: tlsCertPath.trim(),
};
if (onSave) onSave(node);
onClose();
};
const renderChoose = () => (
<S.Content>
<S.Section>
<S.SectionTitle>
<Plus size={14} />
Connect Existing Node
</S.SectionTitle>
<S.SectionDesc>Already running a Lightning node? Connect it here.</S.SectionDesc>
<S.ConnectBtnGroup>
<S.ConnectBtn onClick={() => setView('lnc')}>
<Link size={14} />
Lightning Node Connect
<S.MethodTag>Recommended</S.MethodTag>
</S.ConnectBtn>
<S.ConnectBtnSecondary onClick={() => setView('manual')}>
<Server size={14} />
Manual gRPC Config
</S.ConnectBtnSecondary>
</S.ConnectBtnGroup>
</S.Section>
<S.Divider>
<S.DividerLine />
<S.DividerText>or get a node</S.DividerText>
<S.DividerLine />
</S.Divider>
<S.Section>
<S.SectionTitle>
<Zap size={14} />
Node Providers
</S.SectionTitle>
<S.SectionDesc>
Don&apos;t have a node yet? These providers make it easy.
</S.SectionDesc>
<S.ProviderList>
{PROVIDERS.map(p => (
<S.ProviderCard key={p.name} onClick={() => window.open(p.url, '_blank')}>
<S.ProviderIcon>
<p.icon size={16} />
</S.ProviderIcon>
<S.ProviderInfo>
<S.ProviderName>
{p.name}
{p.tag && <S.ProviderTag>{p.tag}</S.ProviderTag>}
</S.ProviderName>
<S.ProviderDesc>{p.desc}</S.ProviderDesc>
</S.ProviderInfo>
<S.ProviderLink>
<ExternalLink size={12} />
</S.ProviderLink>
</S.ProviderCard>
))}
</S.ProviderList>
</S.Section>
</S.Content>
);
const renderLnc = () => (
<S.Content>
<S.FormSection>
<S.BackLink
onClick={() => {
setView('choose');
setError('');
}}
>
&larr; Back
</S.BackLink>
<S.ConnectHeader>
<Link size={16} />
Connect via Lightning Node Connect
</S.ConnectHeader>
<S.ConnectDesc>Generate a pairing phrase on your node by running:</S.ConnectDesc>
<S.CmdBlock>
litcli sessions add --type admin --label &quot;Lit App&quot;
</S.CmdBlock>
{error && (
<S.ErrorBanner>
<AlertCircle size={14} />
{error}
</S.ErrorBanner>
)}
<S.FieldLabel>Node Label</S.FieldLabel>
<S.Input
placeholder="e.g. My Voltage Node"
value={label}
onChange={e => setLabel(e.target.value)}
/>
<S.FieldLabel>Pairing Phrase</S.FieldLabel>
<S.TextArea
placeholder="Paste the pairing phrase from litcli..."
value={pairingPhrase}
onChange={e => setPairingPhrase(e.target.value)}
rows={3}
/>
<S.FieldLabel>Encryption Password</S.FieldLabel>
<S.Input
type="password"
placeholder="Choose a local password to encrypt credentials"
value={lncPassword}
onChange={e => setLncPassword(e.target.value)}
/>
<S.Hint>
This password encrypts the pairing data stored in your browser. You&apos;ll need
it when reconnecting.
</S.Hint>
<S.SaveBtn
onClick={handleLncConnect}
disabled={loading || !pairingPhrase.trim() || !lncPassword.trim()}
>
{loading ? (
<>
<Spinner size={14} />
Connecting...
</>
) : (
'Connect Node'
)}
</S.SaveBtn>
</S.FormSection>
</S.Content>
);
const renderManual = () => (
<S.Content>
<S.FormSection>
<S.BackLink
onClick={() => {
setView('choose');
setError('');
}}
>
&larr; Back
</S.BackLink>
<S.FieldLabel>Node Name</S.FieldLabel>
<S.Input
placeholder="My Voltage Node"
value={label}
onChange={e => setLabel(e.target.value)}
/>
<S.FieldLabel>gRPC Endpoint</S.FieldLabel>
<S.Input
placeholder="mynode.m.voltageapp.io:10009"
value={rpcServer}
onChange={e => setRpcServer(e.target.value)}
/>
<S.FieldLabel>Admin Macaroon (hex)</S.FieldLabel>
<S.TextArea
placeholder="Paste your admin macaroon hex..."
value={macaroonHex}
onChange={e => setMacaroonHex(e.target.value)}
rows={3}
/>
<S.FieldLabel>TLS Certificate Path</S.FieldLabel>
<S.Input
placeholder="/path/to/tls.cert"
value={tlsCertPath}
onChange={e => setTlsCertPath(e.target.value)}
/>
<S.Hint>Download these from your node provider&apos;s dashboard.</S.Hint>
<S.SaveBtn
onClick={handleManualSave}
disabled={!rpcServer.trim() || !macaroonHex.trim()}
>
Save Node
</S.SaveBtn>
</S.FormSection>
</S.Content>
);
const renderView = () => {
switch (view) {
case 'lnc':
return renderLnc();
case 'manual':
return renderManual();
default:
return renderChoose();
}
};
return ReactDOM.createPortal(
<S.Backdrop onClick={onClose}>
<S.Modal onClick={e => e.stopPropagation()}>
<S.Header>
<S.Title>Add Node</S.Title>
<S.CloseBtn onClick={onClose}>
<X size={18} />
</S.CloseBtn>
</S.Header>
{renderView()}
</S.Modal>
</S.Backdrop>,
document.body,
);
};
export default observer(AddNodeModal);
const Spinner = styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const S = {
Backdrop: styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`,
Modal: styled.div`
background: #0f0a1e;
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: 16px;
width: 440px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
`,
Title: styled.h3`
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
`,
CloseBtn: styled.button`
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 4px;
border-radius: 6px;
&:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
}
`,
Content: styled.div`
padding: 20px 24px 24px;
`,
Section: styled.div`
margin-bottom: 4px;
`,
SectionTitle: styled.div`
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 4px;
`,
SectionDesc: styled.div`
font-size: 12px;
color: #64748b;
margin-bottom: 12px;
line-height: 1.4;
`,
ConnectBtnGroup: styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`,
ConnectBtn: styled.button`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(139, 92, 246, 0.25);
background: rgba(139, 92, 246, 0.08);
color: #a78bfa;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.4);
}
`,
ConnectBtnSecondary: styled.button`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
}
`,
MethodTag: styled.span`
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.03em;
`,
Divider: styled.div`
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
`,
DividerLine: styled.div`
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.06);
`,
DividerText: styled.span`
font-size: 11px;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
`,
ProviderList: styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`,
ProviderCard: styled.button`
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
transition: all 0.15s;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
`,
ProviderIcon: styled.div`
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(139, 92, 246, 0.1);
color: #a78bfa;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`,
ProviderInfo: styled.div`
flex: 1;
min-width: 0;
`,
ProviderName: styled.div`
font-size: 13px;
font-weight: 500;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 6px;
`,
ProviderTag: styled.span`
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.03em;
`,
ProviderDesc: styled.div`
font-size: 11px;
color: #64748b;
margin-top: 1px;
line-height: 1.3;
`,
ProviderLink: styled.div`
color: #475569;
flex-shrink: 0;
`,
FormSection: styled.div``,
BackLink: styled.button`
background: none;
border: none;
color: #64748b;
font-size: 12px;
cursor: pointer;
padding: 0;
margin-bottom: 16px;
&:hover {
color: #a78bfa;
}
`,
ConnectHeader: styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #a78bfa;
margin-bottom: 8px;
`,
ConnectDesc: styled.div`
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
line-height: 1.4;
`,
CmdBlock: styled.div`
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
color: #c4b5fd;
background: rgba(139, 92, 246, 0.06);
border: 1px solid rgba(139, 92, 246, 0.1);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 16px;
word-break: break-all;
line-height: 1.5;
`,
ErrorBanner: styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #ef4444;
font-size: 12px;
`,
FieldLabel: styled.label`
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
margin-top: 12px;
&:first-of-type {
margin-top: 0;
}
`,
Input: styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 13px;
outline: none;
box-sizing: border-box;
&::placeholder {
color: #475569;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
TextArea: styled.textarea`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
outline: none;
resize: vertical;
box-sizing: border-box;
&::placeholder {
color: #475569;
font-family: 'Inter', sans-serif;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
Hint: styled.div`
font-size: 11px;
color: #475569;
margin-top: 8px;
`,
SaveBtn: styled.button<{ disabled?: boolean }>`
width: 100%;
margin-top: 16px;
padding: 12px;
border-radius: 8px;
border: none;
background: ${p =>
p.disabled ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.85)'};
color: white;
font-size: 14px;
font-weight: 500;
cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
&:hover:not(:disabled) {
background: rgba(139, 92, 246, 1);
}
`,
};

View file

@ -98,9 +98,13 @@ const AlertContainer: React.FC = () => {
);
return (
<>
{values(appView.alerts).map(n => (
<AlertToast key={n.id} alert={n} onClose={appView.clearAlert} />
))}
{values(appView.alerts).map(n =>
React.createElement(AlertToast, {
key: n.id,
alert: n,
onClose: appView.clearAlert,
}),
)}
<Container position="top-right" autoClose={5 * 1000} closeButton={closeButton} />
</>
);

View file

@ -47,11 +47,13 @@ const BadgeList: React.FC<Props> = ({ options, value, onChange }) => {
return (
<Wrapper>
{options.map(o => (
<Tip key={o.value} overlay={o.tip}>
<Badge selected={o.value === value} onClick={handleClick(o.value)}>
{o.label}
</Badge>
</Tip>
<div key={o.value} style={{ display: 'inline-block' }}>
<Tip overlay={o.tip}>
<Badge selected={o.value === value} onClick={handleClick(o.value)}>
{o.label}
</Badge>
</Tip>
</div>
))}
</Wrapper>
);

View file

@ -5,54 +5,60 @@ import Dialog from 'rc-dialog';
const GlobalStyles = (theme: Theme) => `
div.rc-dialog {
font-family: ${theme.fonts.open.regular};
font-size: ${theme.sizes.m};
font-family: 'Inter', sans-serif;
font-size: ${theme.sizes.s};
}
div.rc-dialog-content {
color: ${theme.colors.offWhite};
background-color: ${theme.colors.blue};
background-color: ${theme.colors.lightBlue};
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
}
div.rc-dialog-header {
color: ${theme.colors.offWhite};
background-color: ${theme.colors.blue};
color: ${theme.colors.white};
background-color: ${theme.colors.lightBlue};
border-width: 0px;
padding: 32px 40px;
padding: 20px 24px;
}
div.rc-dialog-title {
font-size: 32px;
line-height: 40px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
letter-spacing: -0.02em;
overflow: hidden;
text-overflow: ellipsis;
}
button.rc-dialog-close {
color: ${theme.colors.offWhite};
font-size: ${theme.sizes.xxl};
opacity: 1;
top: 34px;
right: 34px;
width: 24px;
height: 24px;
opacity: 0.5;
top: 20px;
right: 20px;
width: 20px;
height: 20px;
padding: 0;
background-color: ${theme.colors.offWhite};
mask-image: url(${CloseIcon});
padding: 0;
transition: opacity 0.15s;
&:hover {
opacity: 0.6;
opacity: 1;
}
}
span.rc-dialog-close-x:after {
content: "";
}
div.rc-dialog-body {
padding: 0 40px 40px;
padding: 0 24px 24px;
}
div.rc-dialog-mask {
background-color: rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
div.rc-dialog-footer {
border-width: 0px;
padding: 0 40px 40px;
padding: 0 24px 24px;
text-align: left;
}
`;

View file

@ -0,0 +1,316 @@
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';
import { observer } from 'mobx-react-lite';
import { useStore } from 'store';
import { X, Link2, Check, Loader2, AlertCircle } from 'lucide-react';
interface Props {
onClose: () => void;
initialPubkey?: string;
initialAlias?: string;
/** Override the API used to open the channel (for cross-node channel opening) */
sourceApi?: { openChannelSync: (pubkey: string, amount: string) => Promise<any> };
}
const OpenChannelModal: React.FC<Props> = ({
onClose,
initialPubkey,
initialAlias,
sourceApi,
}) => {
const { api } = useStore();
const lndApi = sourceApi || api.lnd;
const [nodePubkey, setNodePubkey] = useState(initialPubkey || '');
const [amount, setAmount] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [fundingTxid, setFundingTxid] = useState('');
const handleOpen = async () => {
if (!nodePubkey.trim() || !amount) return;
setLoading(true);
setError('');
try {
const res = await lndApi.openChannelSync(nodePubkey.trim(), amount);
setFundingTxid(res.fundingTxidStr || res.fundingTxid || '');
setSuccess(true);
} catch (err: any) {
setError(err?.message || 'Failed to open channel');
} finally {
setLoading(false);
}
};
return ReactDOM.createPortal(
<S.Backdrop onClick={onClose}>
<S.Modal onClick={e => e.stopPropagation()}>
<S.Header>
<S.TitleRow>
<S.Icon>
<Link2 size={16} />
</S.Icon>
<S.Title>Open Channel</S.Title>
</S.TitleRow>
<S.CloseBtn onClick={onClose}>
<X size={18} />
</S.CloseBtn>
</S.Header>
{error && (
<S.ErrorBanner>
<AlertCircle size={14} />
{error}
</S.ErrorBanner>
)}
{success ? (
<S.ResultSection>
<S.SuccessIcon>
<Check size={32} />
</S.SuccessIcon>
<S.ResultLabel>Channel Opening</S.ResultLabel>
<S.ResultDetail>
A channel open transaction has been broadcast.
</S.ResultDetail>
{fundingTxid && (
<S.TxBox>
<S.TxLabel>Funding TXID</S.TxLabel>
<S.TxValue>{fundingTxid}</S.TxValue>
</S.TxBox>
)}
<S.ActionBtn onClick={onClose}>Done</S.ActionBtn>
</S.ResultSection>
) : (
<S.FormSection>
{initialAlias && (
<S.ConnectingTo>
Connecting to <strong>{initialAlias}</strong>
</S.ConnectingTo>
)}
<S.FieldLabel>Node Public Key</S.FieldLabel>
<S.Input
placeholder="02abc...@host:port or just pubkey"
value={nodePubkey}
onChange={e => setNodePubkey(e.target.value)}
readOnly={!!initialPubkey}
/>
<S.FieldLabel>Channel Size (sats)</S.FieldLabel>
<S.Input
type="number"
placeholder="e.g. 500000"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
<S.Hint>
Minimum recommended: 100,000 sats. Funds come from your on-chain wallet.
</S.Hint>
<S.ActionBtn
onClick={handleOpen}
disabled={loading || !nodePubkey.trim() || !amount}
>
{loading ? <S.Spinner size={14} /> : 'Open Channel'}
</S.ActionBtn>
</S.FormSection>
)}
</S.Modal>
</S.Backdrop>,
document.body,
);
};
export default observer(OpenChannelModal);
const S = {
Backdrop: styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`,
Modal: styled.div`
background: #0f0a1e;
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: 16px;
width: 420px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
`,
TitleRow: styled.div`
display: flex;
align-items: center;
gap: 10px;
`,
Icon: styled.div`
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(139, 92, 246, 0.12);
color: #a78bfa;
`,
Title: styled.h3`
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
`,
CloseBtn: styled.button`
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 4px;
border-radius: 6px;
&:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
}
`,
FormSection: styled.div`
padding: 20px 24px 24px;
`,
FieldLabel: styled.label`
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
margin-top: 12px;
&:first-of-type {
margin-top: 0;
}
`,
Input: styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 14px;
outline: none;
box-sizing: border-box;
&::placeholder {
color: #475569;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
Hint: styled.div`
font-size: 11px;
color: #475569;
margin-top: 8px;
`,
ActionBtn: styled.button<{ disabled?: boolean }>`
width: 100%;
margin-top: 16px;
padding: 12px;
border-radius: 8px;
border: none;
background: ${p =>
p.disabled ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.85)'};
color: white;
font-size: 14px;
font-weight: 500;
cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
&:hover:not(:disabled) {
background: rgba(139, 92, 246, 1);
}
`,
ResultSection: styled.div`
padding: 20px 24px 24px;
text-align: center;
`,
ResultLabel: styled.div`
font-size: 16px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 8px;
`,
ResultDetail: styled.div`
font-size: 13px;
color: #94a3b8;
margin-bottom: 16px;
`,
SuccessIcon: styled.div`
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
`,
TxBox: styled.div`
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 12px;
text-align: left;
margin-bottom: 8px;
`,
TxLabel: styled.div`
font-size: 10px;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
`,
TxValue: styled.div`
font-size: 12px;
color: #94a3b8;
font-family: 'SF Mono', 'Fira Code', monospace;
word-break: break-all;
`,
ErrorBanner: styled.div`
display: flex;
align-items: center;
gap: 8px;
margin: 12px 24px 0;
padding: 10px 12px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #ef4444;
font-size: 12px;
`,
ConnectingTo: styled.div`
font-size: 13px;
color: #a78bfa;
margin-bottom: 12px;
strong {
color: #e2e8f0;
}
`,
Spinner: styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
};

View file

@ -8,35 +8,36 @@ import Tip from './Tip';
const Styled = {
Wrapper: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
margin-bottom: 4px;
`,
Left: styled.span`
flex: 1;
padding-left: 16px;
text-align: left;
display: flex;
align-items: center;
`,
Center: styled.span`
flex: 1;
text-align: center;
`,
Right: styled.span`
flex: 1;
text-align: right;
svg {
margin-left: 20px;
}
display: flex;
align-items: center;
gap: 8px;
`,
BackLink: styled.a`
text-transform: uppercase;
color: ${props => props.theme.colors.offWhite};
font-size: ${props => props.theme.sizes.xs};
font-weight: 500;
cursor: pointer;
line-height: 36px;
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.15s;
&:hover {
color: ${props => props.theme.colors.offWhite};
opacity: 80%;
color: ${props => props.theme.colors.white};
opacity: 0.8;
text-decoration: none;
}
`,

View file

@ -6,10 +6,12 @@ import Tip from './Tip';
const Styled = {
TileWrap: styled.div`
min-height: 105px;
padding: 15px;
background-color: ${props => props.theme.colors.overlay};
border-radius: 4px;
min-height: 80px;
padding: 16px 20px;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
height: 100%;
`,
Header: styled.div`
display: flex;
@ -26,10 +28,12 @@ const Styled = {
}
`,
Text: styled.div`
font-size: ${props => props.theme.sizes.xl};
line-height: 37px;
letter-spacing: 0.43px;
margin-top: 10px;
font-size: 22px;
font-weight: 600;
line-height: 28px;
letter-spacing: -0.02em;
margin-top: 8px;
font-variant-numeric: tabular-nums;
`,
};

View file

@ -0,0 +1,755 @@
import React, { useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';
import { observer } from 'mobx-react-lite';
import { QRCodeSVG } from 'qrcode.react';
import { useStore } from 'store';
import {
X,
Zap,
Link2,
ArrowDownLeft,
ArrowUpRight,
Copy,
Check,
Loader2,
AlertCircle,
} from 'lucide-react';
type Direction = 'receive' | 'send';
type Method = 'lightning' | 'onchain';
interface Props {
direction: Direction;
onClose: () => void;
}
const formatSats = (sats: number): string => {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k`;
return sats.toLocaleString();
};
const TransactionModal: React.FC<Props> = ({ direction, onClose }) => {
const { api, paymentActivityStore } = useStore();
const [method, setMethod] = useState<Method>('lightning');
const [step, setStep] = useState<'form' | 'result' | 'confirm'>('form');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
// Receive Lightning state
const [invoiceAmount, setInvoiceAmount] = useState('');
const [invoiceMemo, setInvoiceMemo] = useState('');
const [generatedInvoice, setGeneratedInvoice] = useState('');
// Receive On-Chain state
const [generatedAddress, setGeneratedAddress] = useState('');
// Send Lightning state
const [payReq, setPayReq] = useState('');
const [decodedInvoice, setDecodedInvoice] = useState<{
destination: string;
numSatoshis: string;
description: string;
expiry: string;
} | null>(null);
const [paymentResult, setPaymentResult] = useState<{
success: boolean;
preimage?: string;
error?: string;
} | null>(null);
// Send On-Chain state
const [sendAddress, setSendAddress] = useState('');
const [sendAmount, setSendAmount] = useState('');
const [txid, setTxid] = useState('');
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
const handleCreateInvoice = async () => {
setLoading(true);
setError('');
try {
const res = await api.lnd.addInvoice(invoiceAmount || '0', invoiceMemo);
setGeneratedInvoice(res.paymentRequest);
setStep('result');
} catch (err: any) {
setError(err?.message || 'Failed to create invoice');
} finally {
setLoading(false);
}
};
const handleGenerateAddress = async () => {
setLoading(true);
setError('');
try {
const res = await api.lnd.newAddress();
setGeneratedAddress(res.address);
setStep('result');
} catch (err: any) {
setError(err?.message || 'Failed to generate address');
} finally {
setLoading(false);
}
};
const handleDecodeInvoice = async () => {
setLoading(true);
setError('');
try {
const decoded = await api.lnd.decodePayReq(payReq.trim());
setDecodedInvoice({
destination: decoded.destination,
numSatoshis: decoded.numSatoshis,
description: decoded.description,
expiry: decoded.expiry,
});
setStep('confirm');
} catch (err: any) {
setError(err?.message || 'Failed to decode invoice');
} finally {
setLoading(false);
}
};
const handlePayInvoice = async () => {
setLoading(true);
setError('');
try {
const res = await api.lnd.sendPaymentSync(payReq.trim());
if (res.paymentError) {
setPaymentResult({ success: false, error: res.paymentError });
} else {
const preimage =
typeof res.paymentPreimage === 'string'
? res.paymentPreimage
: Array.from(new Uint8Array(res.paymentPreimage as any))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
setPaymentResult({ success: true, preimage });
const amt = decodedInvoice ? parseInt(decodedInvoice.numSatoshis, 10) || 0 : 0;
paymentActivityStore.onPaymentSent(amt);
}
setStep('result');
} catch (err: any) {
setError(err?.message || 'Payment failed');
setLoading(false);
} finally {
setLoading(false);
}
};
const handleSendOnChain = async () => {
setLoading(true);
setError('');
try {
const res = await api.lnd.sendCoins(sendAddress.trim(), sendAmount);
setTxid(res.txid);
setStep('result');
} catch (err: any) {
setError(err?.message || 'Transaction failed');
} finally {
setLoading(false);
}
};
const resetState = () => {
setStep('form');
setError('');
setGeneratedInvoice('');
setGeneratedAddress('');
setPayReq('');
setDecodedInvoice(null);
setPaymentResult(null);
setSendAddress('');
setSendAmount('');
setTxid('');
};
const handleMethodSwitch = (m: Method) => {
setMethod(m);
resetState();
};
const renderReceiveLightning = () => {
if (step === 'result' && generatedInvoice) {
return (
<S.ResultSection>
<S.ResultLabel>Lightning Invoice</S.ResultLabel>
<S.QRWrapper>
<QRCodeSVG
value={generatedInvoice}
size={180}
bgColor="transparent"
fgColor="#e2e8f0"
level="M"
/>
</S.QRWrapper>
<S.InvoiceBox onClick={() => copyToClipboard(generatedInvoice)}>
<S.InvoiceText>{generatedInvoice}</S.InvoiceText>
<S.CopyBtn>{copied ? <Check size={14} /> : <Copy size={14} />}</S.CopyBtn>
</S.InvoiceBox>
{invoiceAmount && (
<S.ResultDetail>
Amount: <strong>{formatSats(Number(invoiceAmount))} sats</strong>
</S.ResultDetail>
)}
<S.ResultHint>Share this invoice with the sender</S.ResultHint>
<S.ActionBtn onClick={resetState}>Create Another</S.ActionBtn>
</S.ResultSection>
);
}
return (
<S.FormSection>
<S.FieldLabel>Amount (sats)</S.FieldLabel>
<S.Input
type="number"
placeholder="0 for any amount"
value={invoiceAmount}
onChange={e => setInvoiceAmount(e.target.value)}
/>
<S.FieldLabel>Memo</S.FieldLabel>
<S.Input
placeholder="What's this for?"
value={invoiceMemo}
onChange={e => setInvoiceMemo(e.target.value)}
/>
<S.ActionBtn onClick={handleCreateInvoice} disabled={loading}>
{loading ? <S.Spinner size={14} /> : 'Create Invoice'}
</S.ActionBtn>
</S.FormSection>
);
};
const renderReceiveOnChain = () => {
if (step === 'result' && generatedAddress) {
return (
<S.ResultSection>
<S.ResultLabel>Bitcoin Address</S.ResultLabel>
<S.QRWrapper>
<QRCodeSVG
value={`bitcoin:${generatedAddress}`}
size={180}
bgColor="transparent"
fgColor="#e2e8f0"
level="M"
/>
</S.QRWrapper>
<S.InvoiceBox onClick={() => copyToClipboard(generatedAddress)}>
<S.InvoiceText style={{ wordBreak: 'break-all' }}>
{generatedAddress}
</S.InvoiceText>
<S.CopyBtn>{copied ? <Check size={14} /> : <Copy size={14} />}</S.CopyBtn>
</S.InvoiceBox>
<S.ResultHint>Send only on-chain Bitcoin to this address</S.ResultHint>
<S.ActionBtn onClick={resetState}>Generate New Address</S.ActionBtn>
</S.ResultSection>
);
}
return (
<S.FormSection>
<S.FormDescription>
Generate a new Bitcoin address to receive on-chain funds.
</S.FormDescription>
<S.ActionBtn onClick={handleGenerateAddress} disabled={loading}>
{loading ? <S.Spinner size={14} /> : 'Generate Address'}
</S.ActionBtn>
</S.FormSection>
);
};
const renderSendLightning = () => {
if (step === 'result' && paymentResult) {
return (
<S.ResultSection>
{paymentResult.success ? (
<>
<S.SuccessIcon>
<Check size={32} />
</S.SuccessIcon>
<S.ResultLabel>Payment Sent</S.ResultLabel>
<S.ResultDetail>
{decodedInvoice && (
<>
Amount:{' '}
<strong>{formatSats(Number(decodedInvoice.numSatoshis))} sats</strong>
</>
)}
</S.ResultDetail>
</>
) : (
<>
<S.ErrorIcon>
<AlertCircle size={32} />
</S.ErrorIcon>
<S.ResultLabel>Payment Failed</S.ResultLabel>
<S.ErrorText>{paymentResult.error}</S.ErrorText>
</>
)}
<S.ActionBtn onClick={resetState}>Send Another</S.ActionBtn>
</S.ResultSection>
);
}
if (step === 'confirm' && decodedInvoice) {
return (
<S.FormSection>
<S.ResultLabel>Confirm Payment</S.ResultLabel>
<S.ConfirmRow>
<span>To</span>
<S.ConfirmValue>
{decodedInvoice.destination.slice(0, 12)}...
{decodedInvoice.destination.slice(-8)}
</S.ConfirmValue>
</S.ConfirmRow>
<S.ConfirmRow>
<span>Amount</span>
<S.ConfirmValue>
{formatSats(Number(decodedInvoice.numSatoshis))} sats
</S.ConfirmValue>
</S.ConfirmRow>
{decodedInvoice.description && (
<S.ConfirmRow>
<span>Memo</span>
<S.ConfirmValue>{decodedInvoice.description}</S.ConfirmValue>
</S.ConfirmRow>
)}
<S.ButtonRow>
<S.SecondaryBtn onClick={resetState}>Cancel</S.SecondaryBtn>
<S.ActionBtn onClick={handlePayInvoice} disabled={loading}>
{loading ? <S.Spinner size={14} /> : 'Confirm & Pay'}
</S.ActionBtn>
</S.ButtonRow>
</S.FormSection>
);
}
return (
<S.FormSection>
<S.FieldLabel>Lightning Invoice</S.FieldLabel>
<S.TextArea
placeholder="Paste a lightning invoice (lnbc...)"
value={payReq}
onChange={e => setPayReq(e.target.value)}
rows={4}
/>
<S.ActionBtn onClick={handleDecodeInvoice} disabled={loading || !payReq.trim()}>
{loading ? <S.Spinner size={14} /> : 'Review Payment'}
</S.ActionBtn>
</S.FormSection>
);
};
const renderSendOnChain = () => {
if (step === 'result' && txid) {
return (
<S.ResultSection>
<S.SuccessIcon>
<Check size={32} />
</S.SuccessIcon>
<S.ResultLabel>Transaction Sent</S.ResultLabel>
<S.InvoiceBox onClick={() => copyToClipboard(txid)}>
<S.InvoiceText style={{ wordBreak: 'break-all' }}>{txid}</S.InvoiceText>
<S.CopyBtn>{copied ? <Check size={14} /> : <Copy size={14} />}</S.CopyBtn>
</S.InvoiceBox>
<S.ResultHint>Transaction ID copied</S.ResultHint>
<S.ActionBtn onClick={resetState}>Send Another</S.ActionBtn>
</S.ResultSection>
);
}
return (
<S.FormSection>
<S.FieldLabel>Bitcoin Address</S.FieldLabel>
<S.Input
placeholder="bc1q..."
value={sendAddress}
onChange={e => setSendAddress(e.target.value)}
/>
<S.FieldLabel>Amount (sats)</S.FieldLabel>
<S.Input
type="number"
placeholder="Amount in satoshis"
value={sendAmount}
onChange={e => setSendAmount(e.target.value)}
/>
<S.ActionBtn
onClick={handleSendOnChain}
disabled={loading || !sendAddress.trim() || !sendAmount}
>
{loading ? <S.Spinner size={14} /> : 'Send Bitcoin'}
</S.ActionBtn>
</S.FormSection>
);
};
const renderContent = () => {
if (direction === 'receive') {
return method === 'lightning' ? renderReceiveLightning() : renderReceiveOnChain();
}
return method === 'lightning' ? renderSendLightning() : renderSendOnChain();
};
return ReactDOM.createPortal(
<S.Backdrop onClick={onClose}>
<S.Modal onClick={e => e.stopPropagation()}>
<S.Header>
<S.TitleRow>
<S.DirectionIcon dir={direction}>
{direction === 'receive' ? (
<ArrowDownLeft size={16} />
) : (
<ArrowUpRight size={16} />
)}
</S.DirectionIcon>
<S.Title>{direction === 'receive' ? 'Receive' : 'Send'} Bitcoin</S.Title>
</S.TitleRow>
<S.CloseBtn onClick={onClose}>
<X size={18} />
</S.CloseBtn>
</S.Header>
<S.MethodTabs>
<S.MethodTab
active={method === 'lightning'}
onClick={() => handleMethodSwitch('lightning')}
>
<Zap size={14} />
Lightning
</S.MethodTab>
<S.MethodTab
active={method === 'onchain'}
onClick={() => handleMethodSwitch('onchain')}
>
<Link2 size={14} />
On-Chain
</S.MethodTab>
</S.MethodTabs>
{error && (
<S.ErrorBanner>
<AlertCircle size={14} />
{error}
</S.ErrorBanner>
)}
{renderContent()}
</S.Modal>
</S.Backdrop>,
document.body,
);
};
export default observer(TransactionModal);
const S = {
Backdrop: styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`,
Modal: styled.div`
background: #0f0a1e;
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: 16px;
width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
`,
TitleRow: styled.div`
display: flex;
align-items: center;
gap: 10px;
`,
DirectionIcon: styled.div<{ dir: Direction }>`
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: ${p =>
p.dir === 'receive' ? 'rgba(34, 197, 94, 0.12)' : 'rgba(139, 92, 246, 0.12)'};
color: ${p => (p.dir === 'receive' ? '#22c55e' : '#a78bfa')};
`,
Title: styled.h3`
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
`,
CloseBtn: styled.button`
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 4px;
border-radius: 6px;
&:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
}
`,
MethodTabs: styled.div`
display: flex;
gap: 4px;
padding: 16px 24px 0;
`,
MethodTab: styled.button<{ active: boolean }>`
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border-radius: 8px;
border: 1px solid
${p => (p.active ? 'rgba(139, 92, 246, 0.3)' : 'rgba(255, 255, 255, 0.06)')};
background: ${p =>
p.active ? 'rgba(139, 92, 246, 0.1)' : 'rgba(255, 255, 255, 0.02)'};
color: ${p => (p.active ? '#a78bfa' : '#64748b')};
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: ${p => (p.active ? '#a78bfa' : '#94a3b8')};
}
`,
FormSection: styled.div`
padding: 20px 24px 24px;
`,
FormDescription: styled.p`
color: #64748b;
font-size: 13px;
margin: 0 0 16px;
line-height: 1.5;
`,
FieldLabel: styled.label`
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
margin-top: 12px;
&:first-of-type {
margin-top: 0;
}
`,
Input: styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 14px;
outline: none;
box-sizing: border-box;
&::placeholder {
color: #475569;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
TextArea: styled.textarea`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #e2e8f0;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
outline: none;
resize: vertical;
box-sizing: border-box;
&::placeholder {
color: #475569;
font-family: 'Inter', sans-serif;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
ActionBtn: styled.button<{ disabled?: boolean }>`
width: 100%;
margin-top: 16px;
padding: 12px;
border-radius: 8px;
border: none;
background: ${p =>
p.disabled ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.85)'};
color: white;
font-size: 14px;
font-weight: 500;
cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
&:hover:not(:disabled) {
background: rgba(139, 92, 246, 1);
}
`,
SecondaryBtn: styled.button`
flex: 1;
margin-top: 16px;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: #94a3b8;
font-size: 14px;
font-weight: 500;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: #e2e8f0;
}
`,
ButtonRow: styled.div`
display: flex;
gap: 8px;
`,
ResultSection: styled.div`
padding: 20px 24px 24px;
text-align: center;
`,
ResultLabel: styled.div`
font-size: 16px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 12px;
`,
QRWrapper: styled.div`
display: flex;
justify-content: center;
padding: 16px;
margin-bottom: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
border: 1px solid rgba(139, 92, 246, 0.1);
`,
ResultDetail: styled.div`
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
`,
ResultHint: styled.div`
font-size: 12px;
color: #475569;
margin-top: 8px;
margin-bottom: 4px;
`,
InvoiceBox: styled.div`
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
padding: 12px;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 8px;
text-align: left;
transition: border-color 0.15s;
&:hover {
border-color: rgba(139, 92, 246, 0.3);
}
`,
InvoiceText: styled.div`
flex: 1;
font-size: 12px;
color: #94a3b8;
font-family: 'SF Mono', 'Fira Code', monospace;
word-break: break-all;
line-height: 1.5;
`,
CopyBtn: styled.div`
color: #64748b;
flex-shrink: 0;
margin-top: 2px;
`,
ConfirmRow: styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
font-size: 13px;
color: #64748b;
`,
ConfirmValue: styled.span`
color: #e2e8f0;
font-weight: 500;
text-align: right;
max-width: 60%;
word-break: break-all;
`,
SuccessIcon: styled.div`
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
`,
ErrorIcon: styled.div`
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
`,
ErrorBanner: styled.div`
display: flex;
align-items: center;
gap: 8px;
margin: 12px 24px 0;
padding: 10px 12px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #ef4444;
font-size: 12px;
`,
ErrorText: styled.div`
font-size: 13px;
color: #ef4444;
margin-bottom: 16px;
`,
Spinner: styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
};

View file

@ -9,7 +9,7 @@ import SessionList from './SessionList';
const Styled = {
Wrapper: styled.section`
padding-top: 80px;
padding: 20px 24px;
`,
Description: styled.div`
margin-bottom: 32px;

View file

@ -6,18 +6,20 @@ interface Props {
}
const PurpleButton = styled.button<Props>`
font-family: ${props => props.theme.fonts.open.regular};
font-size: ${props => props.theme.sizes.m};
font-weight: 600;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
height: auto;
color: white;
background-color: #5d5fef;
border-width: 0;
border-radius: 4px;
padding: 14px 24px;
background-color: ${props => props.theme.colors.iris};
border: none;
border-radius: 8px;
padding: 10px 18px;
transition: all 0.15s ease;
letter-spacing: -0.01em;
&:hover {
background-color: #3d40e7;
background-color: #5355d4;
}
&:focus {
@ -27,22 +29,23 @@ const PurpleButton = styled.button<Props>`
${props =>
props.secondary &&
`
color: #252F4A;
background-color: ${props.theme.colors.white};
color: white;
background-color: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover {
opacity: 0.8;
background-color: ${props.theme.colors.white};
background-color: rgba(255, 255, 255, 0.1);
}
`}
${props =>
props.tertiary &&
`
background-color: #384770;
background-color: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
&:hover {
background-color: #2E3A5C;
background-color: rgba(255, 255, 255, 0.1);
}
`}

View file

@ -1,7 +1,7 @@
import React from 'react';
import styled from '@emotion/styled';
import { usePrefixedTranslation } from 'hooks';
import QRCodeImg from 'qrcode.react';
import { QRCodeSVG as QRCodeImg } from 'qrcode.react';
import { Paragraph } from 'components/base';
import Modal from 'components/common/Modal';

View file

@ -23,12 +23,18 @@ const SessionList: React.FC = () => {
<SessionRowHeader />
<ListContainer>
<AutoSizer disableHeight>
{({ width }) => (
{({ width }: { width: number }) => (
<WindowScroller>
{({ height, isScrolling, onChildScroll, scrollTop, registerChild }) => (
{({
height,
isScrolling,
onChildScroll,
scrollTop,
registerChild,
}: any) => (
<Observer>
{() => (
<div ref={ref => ref && registerChild(ref)}>
<div ref={(ref: any) => ref && registerChild(ref)}>
<List
autoHeight
height={height}
@ -36,12 +42,10 @@ const SessionList: React.FC = () => {
onScroll={onChildScroll}
rowCount={sessionStore.sortedSessions.length}
rowHeight={ROW_HEIGHT}
rowRenderer={({ index, key, style }) => (
<SessionRow
key={key}
style={style}
session={sessionStore.sortedSessions[index]}
/>
rowRenderer={({ index, key, style }: any) => (
<div key={key} style={style}>
<SessionRow session={sessionStore.sortedSessions[index]} />
</div>
)}
scrollTop={scrollTop}
width={width}

View file

@ -0,0 +1,712 @@
import React, { useState, useEffect, useCallback } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { useStore } from 'store';
import { Button } from 'components/base';
import Unit from 'components/common/Unit';
import { Loader2, Search, Compass } from 'lucide-react';
interface NodeRanking {
publicKey: string;
alias: string;
channels: number;
capacity: number;
firstSeen: number;
updatedAt: number;
country?: { en?: string } | null;
iso_code?: string | null;
}
interface UniverseAsset {
assetId: string;
name: string;
supply: string;
proofType: string;
}
interface UniverseStats {
totalAssets: number;
totalGroups: number;
totalProofs: number;
totalSyncs: number;
}
const MEMPOOL_API = 'https://mempool.space/api/v1/lightning/nodes/rankings';
const UNIVERSE_API = 'https://universe.lightning.finance/v1/taproot-assets';
const Styled = {
Wrapper: styled.div`
padding: 24px 32px;
min-height: 100vh;
background: radial-gradient(ellipse at 50% 50%, #0f1218 0%, #090b10 100%);
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
margin-bottom: 24px;
`,
Title: styled.h2`
font-size: 20px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
`,
Tabs: styled.div`
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.04);
padding: 3px;
border-radius: 8px;
`,
Tab: styled.button<{ active?: boolean }>`
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
color: ${p => (p.active ? '#fff' : 'rgba(255,255,255,0.5)')};
background: ${p => (p.active ? 'rgba(255,255,255,0.1)' : 'transparent')};
&:hover {
color: ${p => (p.active ? '#fff' : 'rgba(255,255,255,0.7)')};
}
`,
SearchBar: styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
margin-bottom: 16px;
max-width: 400px;
`,
SearchInput: styled.input`
flex: 1;
border: none;
background: none;
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 13px;
outline: none;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
`,
StatsRow: styled.div`
display: flex;
gap: 16px;
margin-bottom: 20px;
`,
StatChip: styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
`,
StatNum: styled.span`
font-size: 16px;
font-weight: 600;
color: #fff;
font-variant-numeric: tabular-nums;
`,
StatLabel: styled.span`
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.5px;
`,
Table: styled.table`
width: 100%;
border-collapse: collapse;
`,
Th: styled.th`
text-align: left;
padding: 10px 12px;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
white-space: nowrap;
&:last-child {
text-align: right;
}
`,
Td: styled.td`
padding: 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
font-variant-numeric: tabular-nums;
white-space: nowrap;
&:last-child {
text-align: right;
}
`,
Tr: styled.tr`
transition: background 0.1s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
`,
Rank: styled.span`
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
min-width: 30px;
display: inline-block;
`,
NodeAlias: styled.span`
font-weight: 500;
color: #fff;
`,
CapacityBar: styled.div<{ pct: number }>`
height: 3px;
border-radius: 2px;
background: rgba(139, 92, 246, 0.15);
margin-top: 4px;
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: ${p => p.pct}%;
background: rgba(139, 92, 246, 0.6);
border-radius: 2px;
}
`,
Grid: styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
`,
Card: styled.div`
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
`,
CardHeader: styled.div`
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
`,
NodeDot: styled.div<{ active?: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${p => (p.active ? '#10B981' : '#6b7280')};
flex-shrink: 0;
`,
CardTitle: styled.div`
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
`,
CardMeta: styled.div`
display: flex;
gap: 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
`,
MetaItem: styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`,
MetaLabel: styled.span`
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.35);
`,
MetaValue: styled.span`
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
`,
CTARow: styled.div`
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
`,
CountryFlag: styled.span`
font-size: 11px;
margin-left: 6px;
color: rgba(255, 255, 255, 0.35);
`,
AssetIcon: styled.div`
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #6d28d9 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: white;
flex-shrink: 0;
`,
AssetType: styled.span`
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(139, 92, 246, 0.12);
color: #a78bfa;
font-weight: 500;
`,
LoadingState: styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
color: rgba(255, 255, 255, 0.4);
gap: 12px;
font-size: 13px;
`,
Spinner: styled.div`
animation: spin 1s linear infinite;
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`,
EmptyState: styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
`,
EmptyTitle: styled.div`
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 8px;
`,
EmptyDesc: styled.div`
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
max-width: 400px;
margin-bottom: 20px;
`,
};
const formatCapacity = (sats: number) => {
if (sats >= 100000000) return `${(sats / 100000000).toFixed(2)} BTC`;
if (sats >= 1000000) return `${(sats / 1000000).toFixed(1)}M sats`;
if (sats >= 1000) return `${Math.round(sats / 1000)}K sats`;
return `${sats} sats`;
};
const formatSupply = (supply: string) => {
const n = parseInt(supply, 10);
if (isNaN(n)) return supply;
return n.toLocaleString();
};
const formatAge = (timestamp: number) => {
const now = Date.now() / 1000;
const days = Math.floor((now - timestamp) / 86400);
if (days >= 365) return `${Math.floor(days / 365)}y`;
if (days >= 30) return `${Math.floor(days / 30)}mo`;
return `${days}d`;
};
type ExploreTab = 'channels' | 'nodes' | 'assets';
const ExplorePage: React.FC = () => {
const [activeTab, setActiveTab] = useState<ExploreTab>('nodes');
const { channelStore } = useStore();
const channels = channelStore.sortedChannels;
const [nodeRankings, setNodeRankings] = useState<NodeRanking[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [nodeSearch, setNodeSearch] = useState('');
const [assets, setAssets] = useState<UniverseAsset[]>([]);
const [universeStats, setUniverseStats] = useState<UniverseStats | null>(null);
const [assetsLoading, setAssetsLoading] = useState(false);
const [assetSearch, setAssetSearch] = useState('');
const fetchNodes = useCallback(async () => {
setNodesLoading(true);
try {
const [connRes, liqRes] = await Promise.all([
fetch(`${MEMPOOL_API}/connectivity`),
fetch(`${MEMPOOL_API}/liquidity`),
]);
const connData: NodeRanking[] = await connRes.json();
const liqData: NodeRanking[] = await liqRes.json();
const merged = new Map<string, NodeRanking>();
liqData.forEach(n => merged.set(n.publicKey, n));
connData.forEach(n => {
if (!merged.has(n.publicKey)) merged.set(n.publicKey, n);
});
const sorted = Array.from(merged.values()).sort((a, b) => b.capacity - a.capacity);
setNodeRankings(sorted);
} catch (e) {
console.error('Failed to fetch node rankings:', e);
}
setNodesLoading(false);
}, []);
const fetchAssets = useCallback(async () => {
setAssetsLoading(true);
try {
const [rootsRes, statsRes] = await Promise.all([
fetch(`${UNIVERSE_API}/universe/roots`),
fetch(`${UNIVERSE_API}/universe/stats`),
]);
const rootsData = await rootsRes.json();
const statsData = await statsRes.json();
setUniverseStats({
totalAssets: parseInt(statsData.num_total_assets || '0', 10),
totalGroups: parseInt(statsData.num_total_groups || '0', 10),
totalProofs: parseInt(statsData.num_total_proofs || '0', 10),
totalSyncs: parseInt(statsData.num_total_syncs || '0', 10),
});
const roots = rootsData.universe_roots || {};
const parsed: UniverseAsset[] = Object.values(roots).map((root: any) => ({
assetId: root.id?.asset_id || '',
name: root.asset_name || 'Unknown',
supply: root.mssmt_root?.root_sum || '0',
proofType: root.id?.proof_type === 'PROOF_TYPE_TRANSFER' ? 'Transfer' : 'Asset',
}));
parsed.sort((a, b) => a.name.localeCompare(b.name));
setAssets(parsed);
} catch (e) {
console.error('Failed to fetch universe assets:', e);
}
setAssetsLoading(false);
}, []);
useEffect(() => {
if (activeTab === 'nodes' && nodeRankings.length === 0) fetchNodes();
if (activeTab === 'assets' && assets.length === 0) fetchAssets();
}, [activeTab, nodeRankings.length, assets.length, fetchNodes, fetchAssets]);
const maxCapacity = nodeRankings.length > 0 ? nodeRankings[0].capacity : 1;
const filteredNodes = nodeSearch
? nodeRankings.filter(n => n.alias.toLowerCase().includes(nodeSearch.toLowerCase()))
: nodeRankings;
const filteredAssets = assetSearch
? assets.filter(a => a.name.toLowerCase().includes(assetSearch.toLowerCase()))
: assets;
const {
Wrapper,
Header,
Title,
Tabs,
Tab,
SearchBar,
SearchInput,
StatsRow,
StatChip,
StatNum,
StatLabel,
Table,
Th,
Td,
Tr,
Rank,
NodeAlias,
CountryFlag,
CapacityBar,
Grid,
Card,
CardHeader,
NodeDot,
CardTitle,
CardMeta,
MetaItem,
MetaLabel,
MetaValue,
CTARow,
AssetIcon,
AssetType,
LoadingState,
Spinner,
EmptyState,
EmptyTitle,
EmptyDesc,
} = Styled;
return (
<Wrapper>
<Header>
<Title>
<Compass size={18} />
Explore
</Title>
<Tabs>
<Tab active={activeTab === 'channels'} onClick={() => setActiveTab('channels')}>
My Channels
</Tab>
<Tab active={activeTab === 'nodes'} onClick={() => setActiveTab('nodes')}>
Nodes
</Tab>
<Tab active={activeTab === 'assets'} onClick={() => setActiveTab('assets')}>
Assets
</Tab>
</Tabs>
</Header>
{activeTab === 'channels' && (
<>
{channels.length > 0 ? (
<Grid>
{channels.map(ch => (
<Card key={ch.chanId}>
<CardHeader>
<NodeDot active={ch.status === 'Open'} />
<CardTitle>{ch.aliasLabel}</CardTitle>
</CardHeader>
<CardMeta>
<MetaItem>
<MetaLabel>Capacity</MetaLabel>
<MetaValue>
<Unit sats={ch.capacity} />
</MetaValue>
</MetaItem>
<MetaItem>
<MetaLabel>Local</MetaLabel>
<MetaValue>
<Unit sats={ch.localBalance} />
</MetaValue>
</MetaItem>
<MetaItem>
<MetaLabel>Remote</MetaLabel>
<MetaValue>
<Unit sats={ch.remoteBalance} />
</MetaValue>
</MetaItem>
<MetaItem>
<MetaLabel>Uptime</MetaLabel>
<MetaValue>{ch.uptimePercent}</MetaValue>
</MetaItem>
</CardMeta>
<CTARow>
<Button compact primary>
Rebalance
</Button>
<Button compact ghost>
Details
</Button>
</CTARow>
</Card>
))}
</Grid>
) : (
<EmptyState>
<EmptyTitle>No channels yet</EmptyTitle>
<EmptyDesc>
Open your first channel to start routing payments on the Lightning
Network.
</EmptyDesc>
<Button primary>Open Channel</Button>
</EmptyState>
)}
</>
)}
{activeTab === 'nodes' && (
<>
<SearchBar>
<Search size={14} strokeWidth={1.5} />
<SearchInput
placeholder="Search nodes by alias..."
value={nodeSearch}
onChange={e => setNodeSearch(e.target.value)}
/>
</SearchBar>
{nodesLoading ? (
<LoadingState>
<Spinner>
<Loader2 size={20} strokeWidth={1.5} />
</Spinner>
Loading node rankings...
</LoadingState>
) : (
<Table>
<thead>
<tr>
<Th>#</Th>
<Th>Node</Th>
<Th>Channels</Th>
<Th>Capacity</Th>
<Th>Age</Th>
</tr>
</thead>
<tbody>
{filteredNodes.slice(0, 100).map((node, i) => (
<Tr
key={node.publicKey}
onClick={() =>
window.open(
`https://mempool.space/lightning/node/${node.publicKey}`,
'_blank',
)
}
>
<Td>
<Rank>{i + 1}</Rank>
</Td>
<Td>
<NodeAlias>{node.alias || 'Unknown'}</NodeAlias>
{node.iso_code && (
<CountryFlag>{node.country?.en || node.iso_code}</CountryFlag>
)}
</Td>
<Td>{node.channels.toLocaleString()}</Td>
<Td>
{formatCapacity(node.capacity)}
<CapacityBar pct={(node.capacity / maxCapacity) * 100} />
</Td>
<Td>{formatAge(node.firstSeen)}</Td>
</Tr>
))}
</tbody>
</Table>
)}
</>
)}
{activeTab === 'assets' && (
<>
{universeStats && (
<StatsRow>
<StatChip>
<StatNum>{universeStats.totalProofs.toLocaleString()}</StatNum>
<StatLabel>Asset Mints</StatLabel>
</StatChip>
<StatChip>
<StatNum>{universeStats.totalAssets.toLocaleString()}</StatNum>
<StatLabel>Total Assets</StatLabel>
</StatChip>
<StatChip>
<StatNum>{universeStats.totalGroups.toLocaleString()}</StatNum>
<StatLabel>Groups</StatLabel>
</StatChip>
</StatsRow>
)}
<SearchBar>
<Search size={14} strokeWidth={1.5} />
<SearchInput
placeholder="Search name, ID, or group key"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
/>
</SearchBar>
{assetsLoading ? (
<LoadingState>
<Spinner>
<Loader2 size={20} strokeWidth={1.5} />
</Spinner>
Loading Taproot Assets Universe...
</LoadingState>
) : (
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>Supply</Th>
<Th>Type</Th>
<Th>Asset ID</Th>
</tr>
</thead>
<tbody>
{filteredAssets.map(asset => (
<Tr key={asset.assetId}>
<Td>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<AssetIcon>{asset.name.charAt(0).toUpperCase()}</AssetIcon>
<NodeAlias>{asset.name}</NodeAlias>
</div>
</Td>
<Td>{formatSupply(asset.supply)}</Td>
<Td>
<AssetType>{asset.proofType}</AssetType>
</Td>
<Td>
<span
style={{
opacity: 0.4,
fontSize: 11,
fontFamily: 'monospace',
}}
>
{asset.assetId.substring(0, 12)}...
</span>
</Td>
</Tr>
))}
</tbody>
</Table>
)}
</>
)}
</Wrapper>
);
};
export default observer(ExplorePage);

View file

@ -21,12 +21,18 @@ const HistoryList: React.FC = () => {
<HistoryRowHeader />
<ListContainer>
<AutoSizer disableHeight>
{({ width }) => (
{({ width }: { width: number }) => (
<WindowScroller>
{({ height, isScrolling, onChildScroll, scrollTop, registerChild }) => (
{({
height,
isScrolling,
onChildScroll,
scrollTop,
registerChild,
}: any) => (
<Observer>
{() => (
<div ref={ref => ref && registerChild(ref)}>
<div ref={(ref: any) => ref && registerChild(ref)}>
<List
autoHeight
height={height}
@ -34,12 +40,10 @@ const HistoryList: React.FC = () => {
onScroll={onChildScroll}
rowCount={swapStore.sortedSwaps.length}
rowHeight={ROW_HEIGHT}
rowRenderer={({ index, key, style }) => (
<HistoryRow
key={key}
style={style}
swap={swapStore.sortedSwaps[index]}
/>
rowRenderer={({ index, key, style }: any) => (
<div key={key} style={style}>
<HistoryRow swap={swapStore.sortedSwaps[index]} />
</div>
)}
scrollTop={scrollTop}
width={width}

View file

@ -1,30 +1,590 @@
import React from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { usePrefixedTranslation } from 'hooks';
import { useStore } from 'store';
import PageHeader from 'components/common/PageHeader';
import SubServerRequired from 'components/common/SubServerRequired';
import HistoryList from './HistoryList';
import {
ArrowUpRight,
ArrowDownLeft,
Clock,
CheckCircle,
XCircle,
Loader2,
Search,
Activity as ActivityIcon,
ChevronUp,
ChevronDown,
} from 'lucide-react';
import * as LND from 'types/generated/lnd_pb';
type PaymentObj = LND.Payment.AsObject;
type InvoiceObj = LND.Invoice.AsObject;
interface ActivityItem {
id: string;
type: 'payment' | 'invoice';
amount: number;
fee: number;
status: string;
memo: string;
timestamp: number;
hash: string;
}
function paymentToItem(p: PaymentObj): ActivityItem {
const statusMap: Record<number, string> = {
1: 'in_flight',
2: 'succeeded',
3: 'failed',
};
return {
id: `pay-${p.paymentIndex || p.paymentHash}`,
type: 'payment',
amount: Number(p.valueSat) || Number(p.value),
fee: Number(p.feeSat) || Number(p.fee),
status: statusMap[p.status as number] || 'unknown',
memo: '',
timestamp: Number(p.creationDate),
hash: p.paymentHash,
};
}
function invoiceToItem(inv: InvoiceObj): ActivityItem {
const stateMap: Record<number, string> = {
0: 'open',
1: 'settled',
2: 'canceled',
3: 'accepted',
};
return {
id: `inv-${inv.addIndex || String(inv.rHash)}`,
type: 'invoice',
amount: Number(inv.amtPaidSat) || Number(inv.value),
fee: 0,
status: stateMap[inv.state as number] || 'unknown',
memo: inv.memo,
timestamp: inv.state === 1 ? Number(inv.settleDate) : Number(inv.creationDate),
hash: typeof inv.rHash === 'string' ? inv.rHash : '',
};
}
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1)}k`;
return sats.toLocaleString();
}
function timeAgo(unix: number): string {
const diff = Date.now() / 1000 - unix;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return new Date(unix * 1000).toLocaleDateString();
}
const Styled = {
Wrapper: styled.div`
padding: 40px 0;
padding: 28px 36px;
min-height: 100vh;
background: #000000;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
margin-bottom: 28px;
`,
Title: styled.h2`
font-size: 22px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin: 0;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: -0.02em;
`,
Stats: styled.div`
display: flex;
gap: 10px;
`,
StatChip: styled.div`
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
`,
StatVal: styled.span`
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
font-variant-numeric: tabular-nums;
`,
SearchBar: styled.div`
position: relative;
margin-bottom: 16px;
`,
SearchIcon: styled.div`
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.2);
`,
SearchInput: styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 11px 14px 11px 38px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
&::placeholder {
color: rgba(255, 255, 255, 0.2);
}
&:focus {
border-color: rgba(255, 255, 255, 0.12);
}
`,
Tabs: styled.div`
display: flex;
gap: 2px;
margin-bottom: 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 3px;
width: fit-content;
`,
Tab: styled.button<{ active: boolean }>`
background: ${p => (p.active ? 'rgba(255, 255, 255, 0.08)' : 'transparent')};
border: none;
border-radius: 8px;
padding: 7px 16px;
color: ${p => (p.active ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.3)')};
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: rgba(255, 255, 255, 0.6);
}
`,
Table: styled.div`
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
`,
TableHeader: styled.div`
display: grid;
grid-template-columns: 40px 1fr 120px 100px 100px 100px;
padding: 12px 18px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.25);
`,
ThCell: styled.div<{ align?: 'right'; active?: boolean }>`
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
user-select: none;
justify-content: ${p => (p.align === 'right' ? 'flex-end' : 'flex-start')};
color: ${p => (p.active ? 'rgba(255, 255, 255, 0.6)' : 'inherit')};
transition: color 0.15s;
&:hover {
color: rgba(255, 255, 255, 0.5);
}
`,
TableRow: styled.div<{ isEven: boolean }>`
display: grid;
grid-template-columns: 40px 1fr 120px 100px 100px 100px;
padding: 14px 18px;
align-items: center;
background: ${p => (p.isEven ? 'rgba(255, 255, 255, 0.015)' : 'transparent')};
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
&:last-child {
border-bottom: none;
}
`,
DirectionIcon: styled.div<{ dir: 'in' | 'out' }>`
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: ${p =>
p.dir === 'in' ? 'rgba(52, 211, 153, 0.1)' : 'rgba(255, 255, 255, 0.05)'};
color: ${p => (p.dir === 'in' ? '#34d399' : 'rgba(255, 255, 255, 0.5)')};
`,
Memo: styled.div`
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
Hash: styled.div`
font-size: 10px;
color: rgba(255, 255, 255, 0.2);
font-family: 'SF Mono', 'Fira Code', monospace;
margin-top: 2px;
`,
Amount: styled.div<{ dir: 'in' | 'out' }>`
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: ${p => (p.dir === 'in' ? '#34d399' : 'rgba(255, 255, 255, 0.85)')};
text-align: right;
`,
Fee: styled.div`
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.25);
text-align: right;
`,
StatusBadge: styled.div<{ variant: string }>`
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 6px;
background: ${p => {
if (p.variant === 'succeeded' || p.variant === 'settled')
return 'rgba(52, 211, 153, 0.08)';
if (p.variant === 'failed' || p.variant === 'canceled')
return 'rgba(248, 113, 113, 0.08)';
return 'rgba(251, 191, 36, 0.08)';
}};
color: ${p => {
if (p.variant === 'succeeded' || p.variant === 'settled') return '#34d399';
if (p.variant === 'failed' || p.variant === 'canceled') return '#f87171';
return '#fbbf24';
}};
`,
Time: styled.div`
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
text-align: right;
font-variant-numeric: tabular-nums;
`,
LoadingState: styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: rgba(255, 255, 255, 0.3);
gap: 12px;
`,
Spinner: styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
EmptyState: styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: rgba(255, 255, 255, 0.2);
gap: 8px;
font-size: 14px;
`,
};
const HistoryPage: React.FC = () => {
const { l } = usePrefixedTranslation('cmps.history.HistoryPage');
const { swapStore, subServerStore } = useStore();
const StatusIcon: React.FC<{ status: string }> = ({ status }) => {
if (status === 'succeeded' || status === 'settled') return <CheckCircle size={12} />;
if (status === 'failed' || status === 'canceled') return <XCircle size={12} />;
return <Clock size={12} />;
};
type TabFilter = 'all' | 'payments' | 'invoices';
type SortKey = 'details' | 'amount' | 'fee' | 'status' | 'time';
type SortDir = 'asc' | 'desc';
const HistoryPage: React.FC = () => {
const { api } = useStore();
const [items, setItems] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [tab, setTab] = useState<TabFilter>('all');
const [sortBy, setSortBy] = useState<SortKey>('time');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const fetchActivity = useCallback(async () => {
setLoading(true);
try {
const [paymentsRes, invoicesRes] = await Promise.all([
api.lnd.listPayments(),
api.lnd.listInvoices(),
]);
const payItems = paymentsRes.paymentsList.map(paymentToItem);
const invItems = invoicesRes.invoicesList
.map(invoiceToItem)
.filter(i => i.status !== 'open' && i.status !== 'canceled');
const merged = [...payItems, ...invItems].sort((a, b) => b.timestamp - a.timestamp);
setItems(merged);
} catch (err) {
console.error('Failed to fetch activity', err);
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
fetchActivity();
}, [fetchActivity]);
const toggleSort = (key: SortKey) => {
if (sortBy === key) {
setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortBy(key);
setSortDir(key === 'time' ? 'desc' : 'asc');
}
};
const filtered = items
.filter(item => {
if (tab === 'payments' && item.type !== 'payment') return false;
if (tab === 'invoices' && item.type !== 'invoice') return false;
if (search) {
const q = search.toLowerCase();
return (
item.memo.toLowerCase().includes(q) ||
item.hash.toLowerCase().includes(q) ||
item.amount.toString().includes(q)
);
}
return true;
})
.sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case 'details':
cmp = (a.memo || a.hash).localeCompare(b.memo || b.hash);
break;
case 'amount':
cmp = a.amount - b.amount;
break;
case 'fee':
cmp = a.fee - b.fee;
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'time':
default:
cmp = a.timestamp - b.timestamp;
break;
}
return sortDir === 'asc' ? cmp : -cmp;
});
const totalSent = items
.filter(i => i.type === 'payment' && i.status === 'succeeded')
.reduce((s, i) => s + i.amount, 0);
const totalReceived = items
.filter(i => i.type === 'invoice' && i.status === 'settled')
.reduce((s, i) => s + i.amount, 0);
const totalFees = items
.filter(i => i.type === 'payment' && i.status === 'succeeded')
.reduce((s, i) => s + i.fee, 0);
const {
Wrapper,
Header,
Title,
Stats,
StatChip,
StatVal,
SearchBar,
SearchIcon,
SearchInput,
Tabs,
Tab,
Table,
TableHeader,
ThCell,
TableRow,
DirectionIcon,
Memo,
Hash,
Amount,
Fee,
StatusBadge,
Time,
LoadingState,
Spinner,
EmptyState,
} = Styled;
const { Wrapper } = Styled;
return (
<SubServerRequired status={subServerStore.subServers.loop}>
<Wrapper>
<PageHeader title={l('pageTitle')} onExportClick={swapStore.exportSwaps} />
<HistoryList />
</Wrapper>
</SubServerRequired>
<Wrapper>
<Header>
<Title>
<ActivityIcon size={18} />
Activity
</Title>
<Stats>
<StatChip>
<ArrowUpRight size={14} color="#a78bfa" />
Sent <StatVal>{formatSats(totalSent)} sats</StatVal>
</StatChip>
<StatChip>
<ArrowDownLeft size={14} color="#22c55e" />
Received <StatVal>{formatSats(totalReceived)} sats</StatVal>
</StatChip>
<StatChip>
Fees <StatVal>{formatSats(totalFees)} sats</StatVal>
</StatChip>
</Stats>
</Header>
<Tabs>
<Tab active={tab === 'all'} onClick={() => setTab('all')}>
All ({items.length})
</Tab>
<Tab active={tab === 'payments'} onClick={() => setTab('payments')}>
Payments ({items.filter(i => i.type === 'payment').length})
</Tab>
<Tab active={tab === 'invoices'} onClick={() => setTab('invoices')}>
Invoices ({items.filter(i => i.type === 'invoice').length})
</Tab>
</Tabs>
<SearchBar>
<SearchIcon>
<Search size={14} />
</SearchIcon>
<SearchInput
placeholder="Search by memo or payment hash..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</SearchBar>
{loading ? (
<LoadingState>
<Spinner size={24} />
Loading activity...
</LoadingState>
) : filtered.length === 0 ? (
<EmptyState>
<ActivityIcon size={32} />
{search ? 'No results match your search' : 'No activity yet'}
</EmptyState>
) : (
<Table>
<TableHeader>
<div></div>
<ThCell active={sortBy === 'details'} onClick={() => toggleSort('details')}>
Details
{sortBy === 'details' &&
(sortDir === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</ThCell>
<ThCell
align="right"
active={sortBy === 'amount'}
onClick={() => toggleSort('amount')}
>
Amount
{sortBy === 'amount' &&
(sortDir === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</ThCell>
<ThCell
align="right"
active={sortBy === 'fee'}
onClick={() => toggleSort('fee')}
>
Fee
{sortBy === 'fee' &&
(sortDir === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</ThCell>
<ThCell
align="right"
active={sortBy === 'status'}
onClick={() => toggleSort('status')}
>
Status
{sortBy === 'status' &&
(sortDir === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</ThCell>
<ThCell
align="right"
active={sortBy === 'time'}
onClick={() => toggleSort('time')}
>
Time
{sortBy === 'time' &&
(sortDir === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />)}
</ThCell>
</TableHeader>
{filtered.map((item, i) => {
const dir = item.type === 'invoice' ? 'in' : 'out';
return (
<TableRow key={item.id} isEven={i % 2 === 0}>
<DirectionIcon dir={dir}>
{dir === 'in' ? (
<ArrowDownLeft size={14} />
) : (
<ArrowUpRight size={14} />
)}
</DirectionIcon>
<div>
<Memo>
{item.memo ||
(item.type === 'payment' ? 'Lightning Payment' : 'Invoice')}
</Memo>
<Hash>
{item.hash.slice(0, 16)}...{item.hash.slice(-8)}
</Hash>
</div>
<Amount dir={dir}>
{dir === 'in' ? '+' : '-'}
{formatSats(item.amount)} sats
</Amount>
<Fee>{item.fee > 0 ? `${formatSats(item.fee)} sats` : '—'}</Fee>
<div style={{ textAlign: 'right' }}>
<StatusBadge variant={item.status}>
<StatusIcon status={item.status} />
{item.status}
</StatusBadge>
</div>
<Time>{timeAgo(item.timestamp)}</Time>
</TableRow>
);
})}
</Table>
)}
</Wrapper>
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,115 +1,20 @@
import React, { useCallback, useState } from 'react';
import React from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import DashUX from 'assets/images/home_dash_ss.png';
import LoopUX from 'assets/images/home_loop_ss.png';
import { ReactComponent as Youtube } from 'assets/images/youtube.svg';
import { usePrefixedTranslation } from 'hooks';
import { useStore } from 'store';
import {
BoltOutlined,
Button,
Column,
Display,
Paragraph,
QRCode,
Row,
} from 'components/base';
import PurpleButton from 'components/connect/PurpleButton';
import QRCodeModal from 'components/connect/QRCodeModal';
import YoutubeModal from './YoutubeModal';
import GraphVisualization from './GraphVisualization';
const Styled = {
Wrapper: styled.div`
padding: 72px 0;
`,
PurpleButton: styled(PurpleButton)`
font-size: ${props => props.theme.sizes.s};
line-height: 24px;
padding: 8px 16px;
margin-right: 24px;
`,
YoutubeButton: styled(Button)`
font-family: ${props => props.theme.fonts.open.semiBold};
padding-left: 0;
svg {
margin-right: 16px;
}
`,
Column: styled(Column)`
max-width: 480px;
`,
Image: styled.img`
height: 100%;
width: 100%;
margin-bottom: 24px;
`,
};
const HomePage: React.FC = () => {
const { l } = usePrefixedTranslation('cmps.home.HomePage');
const [qrUrl, setQrUrl] = useState('');
const [showVideo, setShowVideo] = useState(false);
const { sessionStore } = useStore();
const openQRModal = useCallback(
async () => setQrUrl(await sessionStore.getNewSessionUrl()),
[],
);
const closeQRModal = useCallback(() => setQrUrl(''), []);
const toggleVideoModal = useCallback(() => setShowVideo(v => !v), []);
const { Wrapper, PurpleButton, YoutubeButton, Column, Image } = Styled;
return (
<Wrapper>
<Display semiBold space={16}>
{l('pageTitle')}
</Display>
<Paragraph space={32}>{l('connectDesc')}</Paragraph>
<Paragraph space={40}>
<PurpleButton onClick={sessionStore.connectToTerminalWeb}>
<BoltOutlined />
{l('connectTerminalBtn')}
</PurpleButton>
<PurpleButton secondary onClick={openQRModal}>
<QRCode />
{l('connectQrBtn')}
</PurpleButton>
</Paragraph>
<Paragraph space={32}>{l('learnDesc')}</Paragraph>
<Paragraph space={40}>
<YoutubeButton ghost borderless compact onClick={toggleVideoModal}>
<Youtube />
Learn More
</YoutubeButton>
</Paragraph>
<Display semiBold space={16}>
{l('whatsDiff')}
</Display>
<Paragraph space={24}>{l('diffDesc')}</Paragraph>
<Row>
<Column>
<Image src={LoopUX} alt={l('loopTitle')} />
<Paragraph semiBold space={8}>
{l('loopTitle')}
</Paragraph>
<Paragraph muted>{l('loopDesc')}</Paragraph>
</Column>
<Column>
<Image src={DashUX} alt={l('dashTitle')} />
<Paragraph semiBold space={8}>
{l('dashTitle')}
</Paragraph>
<Paragraph muted>{l('dashDesc')}</Paragraph>
</Column>
</Row>
<QRCodeModal url={qrUrl} visible={!!qrUrl} onClose={closeQRModal} />
<YoutubeModal
videoId="5kH1ByxjkTM"
visible={showVideo}
onClose={toggleVideoModal}
/>
</Wrapper>
<Styled.Wrapper>
<GraphVisualization />
</Styled.Wrapper>
);
};

View file

@ -0,0 +1,414 @@
import React, { useState, useCallback } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import {
Copy,
Check,
Wallet,
ArrowDownLeft,
ArrowUpRight,
Zap,
WifiOff,
Loader2,
RefreshCw,
} from 'lucide-react';
import { NodeConnection, NodeChannel } from 'store/stores/nodeConnectionStore';
interface Props {
node: NodeConnection;
colorIndex: number;
onOpenChannel?: (targetPubkey: string, targetAlias: string) => void;
}
const NODE_COLORS = ['#6366f1', '#0ea5e9', '#f59e0b', '#22c55e', '#ec4899'];
const formatSats = (sats: number) => {
if (sats >= 100000000) return `${(sats / 100000000).toFixed(2)} BTC`;
if (sats >= 1000000) return `${(sats / 1000000).toFixed(1)}M sats`;
return `${sats.toLocaleString()} sats`;
};
const NodePanel: React.FC<Props> = ({ node, colorIndex, onOpenChannel }) => {
const [copiedPk, setCopiedPk] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const accentColor = NODE_COLORS[colorIndex % NODE_COLORS.length];
const handleCopy = useCallback(async () => {
if (!node.pubkey) return;
try {
await navigator.clipboard.writeText(node.pubkey);
} catch {
const el = document.createElement('textarea');
el.value = node.pubkey;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
setCopiedPk(true);
setTimeout(() => setCopiedPk(false), 2000);
}, [node.pubkey]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await node.fetchInfo();
await node.fetchChannels();
setRefreshing(false);
}, [node]);
if (!node.connected) {
return (
<S.Panel>
<S.DisconnectedState>
<WifiOff size={24} strokeWidth={1.5} />
<S.DisconnectedLabel>{node.displayName}</S.DisconnectedLabel>
<S.DisconnectedHint>Not connected</S.DisconnectedHint>
</S.DisconnectedState>
</S.Panel>
);
}
const activeChannels = node.channels.filter(ch => ch.active);
const totalCapacity = node.channels.reduce((s, ch) => s + ch.capacity, 0);
return (
<S.Panel>
<S.Header>
<S.NodeBadge style={{ background: accentColor }}>{node.initial}</S.NodeBadge>
<S.HeaderInfo>
<S.Alias>{node.displayName}</S.Alias>
<S.PubkeyRow>
<S.Pubkey>
{node.pubkey
? `${node.pubkey.slice(0, 10)}...${node.pubkey.slice(-6)}`
: ''}
</S.Pubkey>
{node.pubkey && (
<S.CopyBtn onClick={handleCopy} title="Copy pubkey">
{copiedPk ? <Check size={10} /> : <Copy size={10} />}
</S.CopyBtn>
)}
</S.PubkeyRow>
</S.HeaderInfo>
<S.RefreshBtn onClick={handleRefresh} title="Refresh data">
{refreshing ? (
<SpinnerSmall size={12} />
) : (
<RefreshCw size={12} strokeWidth={1.5} />
)}
</S.RefreshBtn>
</S.Header>
<S.BalanceGrid>
<S.BalanceCard>
<S.BalanceIcon>
<Wallet size={12} />
</S.BalanceIcon>
<S.BalanceLabel>On-chain</S.BalanceLabel>
<S.BalanceValue>{formatSats(node.walletBalanceConfirmed)}</S.BalanceValue>
</S.BalanceCard>
<S.BalanceCard>
<S.BalanceIcon>
<ArrowUpRight size={12} />
</S.BalanceIcon>
<S.BalanceLabel>Outbound</S.BalanceLabel>
<S.BalanceValue>{formatSats(node.channelBalanceLocal)}</S.BalanceValue>
</S.BalanceCard>
<S.BalanceCard>
<S.BalanceIcon>
<ArrowDownLeft size={12} />
</S.BalanceIcon>
<S.BalanceLabel>Inbound</S.BalanceLabel>
<S.BalanceValue>{formatSats(node.channelBalanceRemote)}</S.BalanceValue>
</S.BalanceCard>
</S.BalanceGrid>
<S.SectionHeader>
<S.SectionTitle>
<Zap size={12} />
Channels ({activeChannels.length}/{node.channels.length})
</S.SectionTitle>
{totalCapacity > 0 && (
<S.SectionMeta>{formatSats(totalCapacity)} total</S.SectionMeta>
)}
</S.SectionHeader>
<S.ChannelList>
{node.channels.length === 0 ? (
<S.EmptyChannels>No channels yet</S.EmptyChannels>
) : (
node.channels.map(ch => (
<ChannelRow
key={ch.chanId || ch.remotePubkey}
channel={ch}
accentColor={accentColor}
onOpenChannel={onOpenChannel}
/>
))
)}
</S.ChannelList>
</S.Panel>
);
};
const ChannelRow: React.FC<{
channel: NodeChannel;
accentColor: string;
onOpenChannel?: (pubkey: string, alias: string) => void;
}> = observer(({ channel, accentColor }) => {
const total = channel.localBalance + channel.remoteBalance;
const localPct = total > 0 ? (channel.localBalance / total) * 100 : 50;
return (
<S.ChannelItem>
<S.ChannelDot active={channel.active} />
<S.ChannelInfo>
<S.ChannelPeer>
{channel.alias || `${channel.remotePubkey.slice(0, 12)}...`}
</S.ChannelPeer>
<S.ChannelBar>
<S.ChannelBarLocal style={{ width: `${localPct}%`, background: accentColor }} />
</S.ChannelBar>
<S.ChannelMeta>
<span>{formatSats(channel.localBalance)}</span>
<span>{formatSats(channel.capacity)}</span>
<span>{formatSats(channel.remoteBalance)}</span>
</S.ChannelMeta>
</S.ChannelInfo>
</S.ChannelItem>
);
});
export default observer(NodePanel);
const SpinnerSmall = styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const S = {
Panel: styled.div`
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
min-width: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
}
`,
Header: styled.div`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
`,
NodeBadge: styled.div`
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: white;
flex-shrink: 0;
`,
HeaderInfo: styled.div`
flex: 1;
min-width: 0;
`,
Alias: styled.div`
font-size: 15px;
font-weight: 600;
color: #e2e8f0;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
PubkeyRow: styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
`,
Pubkey: styled.span`
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 0.02em;
`,
CopyBtn: styled.button`
background: none;
border: none;
color: #a78bfa;
cursor: pointer;
padding: 1px;
display: flex;
align-items: center;
border-radius: 3px;
&:hover {
color: #c4b5fd;
}
`,
RefreshBtn: styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
flex-shrink: 0;
&:hover {
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.05);
}
`,
BalanceGrid: styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin-bottom: 20px;
`,
BalanceCard: styled.div`
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 10px;
padding: 10px;
`,
BalanceIcon: styled.div`
color: rgba(255, 255, 255, 0.3);
margin-bottom: 4px;
`,
BalanceLabel: styled.div`
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
`,
BalanceValue: styled.div`
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
`,
SectionHeader: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
`,
SectionTitle: styled.div`
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.04em;
`,
SectionMeta: styled.span`
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
font-variant-numeric: tabular-nums;
`,
ChannelList: styled.div`
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
`,
EmptyChannels: styled.div`
text-align: center;
color: rgba(255, 255, 255, 0.2);
font-size: 12px;
padding: 24px 0;
`,
ChannelItem: styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.015);
border: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.1s;
&:hover {
background: rgba(255, 255, 255, 0.035);
}
`,
ChannelDot: styled.div<{ active: boolean }>`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${p => (p.active ? '#22c55e' : '#64748b')};
margin-top: 5px;
flex-shrink: 0;
`,
ChannelInfo: styled.div`
flex: 1;
min-width: 0;
`,
ChannelPeer: styled.div`
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.75);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
`,
ChannelBar: styled.div`
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
margin-bottom: 3px;
`,
ChannelBarLocal: styled.div`
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
`,
ChannelMeta: styled.div`
display: flex;
justify-content: space-between;
font-size: 9px;
color: rgba(255, 255, 255, 0.25);
font-variant-numeric: tabular-nums;
`,
DisconnectedState: styled.div`
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.2);
gap: 8px;
`,
DisconnectedLabel: styled.div`
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
`,
DisconnectedHint: styled.div`
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
`,
};

View file

@ -0,0 +1,339 @@
import React, { useState, useMemo } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { useStore } from 'store';
import { Link2, ArrowRightLeft, Zap, Plus } from 'lucide-react';
import NodePanel from './NodePanel';
import OpenChannelModal from 'components/common/OpenChannelModal';
const formatSats = (sats: number) => {
if (sats >= 100000000) return `${(sats / 100000000).toFixed(2)} BTC`;
if (sats >= 1000000) return `${(sats / 1000000).toFixed(1)}M sats`;
return `${sats.toLocaleString()} sats`;
};
const SplitNodeView: React.FC = () => {
const { nodeConnectionStore } = useStore();
const left = nodeConnectionStore.activeLeft;
const right = nodeConnectionStore.activeRight;
const [showOpenChannel, setShowOpenChannel] = useState(false);
const [channelDirection, setChannelDirection] = useState<
'left-to-right' | 'right-to-left'
>('left-to-right');
const existingChannel = nodeConnectionStore.channelBetweenActive;
const channelFromRight = useMemo(() => {
if (!left?.connected || !right?.connected) return null;
return right.channels.find(ch => ch.remotePubkey === left.pubkey) || null;
}, [left, right]);
const sourceNode = channelDirection === 'left-to-right' ? left : right;
const targetNode = channelDirection === 'left-to-right' ? right : left;
const handleOpenChannel = () => {
setShowOpenChannel(true);
};
const handleSwapDirection = () => {
setChannelDirection(d => (d === 'left-to-right' ? 'right-to-left' : 'left-to-right'));
};
if (!left || !right) {
return (
<S.EmptyState>
<Plus size={32} strokeWidth={1} />
<S.EmptyTitle>Connect a Second Node</S.EmptyTitle>
<S.EmptyDesc>
Add another node using the node picker in the sidebar to see both nodes side by
side and connect them.
</S.EmptyDesc>
</S.EmptyState>
);
}
return (
<S.Wrapper>
<S.PanelContainer>
<NodePanel node={left} colorIndex={0} />
<S.Divider>
{existingChannel || channelFromRight ? (
<S.ChannelStatus>
<S.ChannelStatusIcon connected>
<Link2 size={16} />
</S.ChannelStatusIcon>
<S.ChannelStatusLabel>Connected</S.ChannelStatusLabel>
<S.ChannelStatusDetail>
{formatSats((existingChannel || channelFromRight)!.capacity)} channel
</S.ChannelStatusDetail>
<S.ChannelBalanceBar>
<S.ChannelBalanceLocal
style={{
width: `${
((existingChannel || channelFromRight)!.localBalance /
Math.max(
(existingChannel || channelFromRight)!.localBalance +
(existingChannel || channelFromRight)!.remoteBalance,
1,
)) *
100
}%`,
}}
/>
</S.ChannelBalanceBar>
<S.ChannelBalanceMeta>
<span>
Local: {formatSats((existingChannel || channelFromRight)!.localBalance)}
</span>
<span>
Remote:{' '}
{formatSats((existingChannel || channelFromRight)!.remoteBalance)}
</span>
</S.ChannelBalanceMeta>
<S.DividerOpenMore onClick={handleOpenChannel}>
<Plus size={12} />
Open Another Channel
</S.DividerOpenMore>
</S.ChannelStatus>
) : (
<S.ConnectAction>
<S.ConnectIcon>
<Zap size={20} />
</S.ConnectIcon>
<S.ConnectLabel>No Channel</S.ConnectLabel>
<S.ConnectHint>
Connect these nodes by opening a channel between them.
</S.ConnectHint>
<S.DirectionPicker onClick={handleSwapDirection}>
<S.DirectionNode active={channelDirection === 'left-to-right'}>
{left.displayName.slice(0, 12)}
</S.DirectionNode>
<ArrowRightLeft size={12} />
<S.DirectionNode active={channelDirection === 'right-to-left'}>
{right.displayName.slice(0, 12)}
</S.DirectionNode>
</S.DirectionPicker>
<S.DirectionHint>
{sourceNode?.displayName} opens channel to {targetNode?.displayName}
</S.DirectionHint>
<S.ConnectBtn onClick={handleOpenChannel}>
<Link2 size={14} />
Open Channel
</S.ConnectBtn>
</S.ConnectAction>
)}
</S.Divider>
<NodePanel node={right} colorIndex={1} />
</S.PanelContainer>
{showOpenChannel && targetNode && sourceNode && (
<OpenChannelModal
onClose={() => setShowOpenChannel(false)}
initialPubkey={targetNode.pubkey}
initialAlias={targetNode.displayName}
sourceApi={sourceNode.api || undefined}
/>
)}
</S.Wrapper>
);
};
export default observer(SplitNodeView);
const S = {
Wrapper: styled.div`
width: 100%;
height: 100%;
background: radial-gradient(ellipse at 50% 50%, #0a0d14 0%, #050709 100%);
overflow: hidden;
`,
PanelContainer: styled.div`
display: flex;
height: 100%;
`,
Divider: styled.div`
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-left: 1px solid rgba(255, 255, 255, 0.04);
border-right: 1px solid rgba(255, 255, 255, 0.04);
padding: 20px 12px;
`,
ChannelStatus: styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
`,
ChannelStatusIcon: styled.div<{ connected?: boolean }>`
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: ${p =>
p.connected ? 'rgba(34, 197, 94, 0.12)' : 'rgba(139, 92, 246, 0.12)'};
color: ${p => (p.connected ? '#22c55e' : '#a78bfa')};
margin-bottom: 4px;
`,
ChannelStatusLabel: styled.div`
font-size: 13px;
font-weight: 600;
color: #22c55e;
`,
ChannelStatusDetail: styled.div`
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
`,
ChannelBalanceBar: styled.div`
width: 100%;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
margin-top: 4px;
`,
ChannelBalanceLocal: styled.div`
height: 100%;
border-radius: 2px;
background: #6366f1;
transition: width 0.3s ease;
`,
ChannelBalanceMeta: styled.div`
display: flex;
justify-content: space-between;
width: 100%;
font-size: 9px;
color: rgba(255, 255, 255, 0.25);
font-variant-numeric: tabular-nums;
`,
DividerOpenMore: styled.button`
display: flex;
align-items: center;
gap: 4px;
margin-top: 12px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid rgba(139, 92, 246, 0.15);
background: rgba(139, 92, 246, 0.06);
color: #a78bfa;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(139, 92, 246, 0.12);
border-color: rgba(139, 92, 246, 0.3);
}
`,
ConnectAction: styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
`,
ConnectIcon: styled.div`
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(139, 92, 246, 0.08);
color: #a78bfa;
margin-bottom: 4px;
`,
ConnectLabel: styled.div`
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
`,
ConnectHint: styled.div`
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
line-height: 1.4;
max-width: 180px;
margin-bottom: 8px;
`,
DirectionPicker: styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: rgba(139, 92, 246, 0.2);
color: rgba(255, 255, 255, 0.6);
}
`,
DirectionNode: styled.span<{ active?: boolean }>`
color: ${p => (p.active ? '#a78bfa' : 'rgba(255, 255, 255, 0.35)')};
font-weight: ${p => (p.active ? 600 : 400)};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60px;
`,
DirectionHint: styled.div`
font-size: 9px;
color: rgba(255, 255, 255, 0.2);
font-style: italic;
max-width: 180px;
`,
ConnectBtn: styled.button`
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 10px 20px;
border-radius: 8px;
border: none;
background: rgba(139, 92, 246, 0.7);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(139, 92, 246, 0.9);
}
`,
EmptyState: styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.2);
gap: 12px;
background: radial-gradient(ellipse at 50% 50%, #0a0d14 0%, #050709 100%);
`,
EmptyTitle: styled.div`
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
`,
EmptyDesc: styled.div`
font-size: 13px;
color: rgba(255, 255, 255, 0.25);
text-align: center;
max-width: 320px;
line-height: 1.5;
`,
};

View file

@ -2,38 +2,34 @@ import React from 'react';
import { observer } from 'mobx-react-lite';
import { Global, Theme } from '@emotion/react';
import styled from '@emotion/styled';
import { useStore } from 'store';
import { Background, Menu } from 'components/base';
import { Background } from 'components/base';
import Sidebar from './Sidebar';
interface CollapsedProps {
collapsed: boolean;
fullWidth?: boolean;
}
const SIDEBAR_WIDTH = '220px';
const GlobalStyles = (theme: Theme) => `
.rc-select-dropdown {
padding-top: 10px;
padding-top: 6px;
background-color: transparent;
& > div {
color: ${theme.colors.offWhite};
background-color: ${theme.colors.lightBlue};
border-width: 0;
border-radius: 8px;
box-shadow: 0px 16px 16px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
}
.rc-select-item {
color: ${theme.colors.white};
font-family: ${theme.fonts.open.regular};
font-weight: 600;
font-size: ${theme.sizes.s};
line-height: 24px;
padding: 16px;
border-bottom: 1px solid ${theme.colors.paleBlue};
color: ${theme.colors.offWhite};
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: ${theme.sizes.xs};
line-height: 20px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
&:last-of-type {
border-bottom: none;
@ -41,95 +37,61 @@ const GlobalStyles = (theme: Theme) => `
&:hover {
color: ${theme.colors.white};
background-color: ${theme.colors.blue};
background-color: rgba(255, 255, 255, 0.06);
cursor: pointer;
}
& > .rc-select-item-option-state {
top: 16px;
right: 12px;
}
top: 10px;
right: 10px;
}
}
.ReactCollapse--collapse {
transition: height 500ms;
transition: height 300ms ease;
}
`;
const Styled = {
Container: styled.div<{ fullWidth: boolean }>`
position: relative;
Container: styled.div`
display: flex;
height: 100%;
max-width: ${props => (props.fullWidth ? '100%' : '1440px')};
width: ${props => (props.fullWidth ? '100%' : '100%')};
margin: 0 auto;
width: 100%;
`,
Hamburger: styled.span<CollapsedProps>`
display: inline-block;
position: ${props => (props.collapsed ? 'absolute' : 'fixed')};
top: 35px;
margin-left: 10px;
z-index: 2;
padding: 4px;
&:hover {
color: ${props => props.theme.colors.blue};
background-color: ${props => props.theme.colors.offWhite};
border-radius: 24px;
cursor: pointer;
}
`,
Aside: styled.aside<CollapsedProps>`
Aside: styled.aside`
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 1;
width: ${SIDEBAR_WIDTH};
z-index: 10;
background-color: ${props => props.theme.colors.darkBlue};
overflow: hidden;
/* change sidebar dimensions based on collapsed toggle */
width: ${props => (props.collapsed ? '0' : '285px')};
padding: ${props => (props.collapsed ? '0' : '0 15px')};
transition: all 0.2s;
/* set a width on the child to improve the collapse animation */
& > div {
width: 255px;
}
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
`,
Content: styled.div<CollapsedProps>`
Content: styled.div`
flex: 1;
height: 100%;
margin-left: ${props => (props.collapsed || props.fullWidth ? '0' : '285px')};
padding: ${props => (props.fullWidth ? '0' : '0 15px')};
transition: ${props => (props.fullWidth ? '0' : 'all 0.2s')};
@media (max-width: 1200px) {
margin-left: 0;
}
margin-left: ${SIDEBAR_WIDTH};
overflow: auto;
`,
Fluid: styled.div`
Main: styled.div`
height: 100%;
padding: 0;
`,
};
export const Layout: React.FC = ({ children }) => {
const { settingsStore, appView } = useStore();
const { Container, Hamburger, Aside, Content, Fluid } = Styled;
const { Container, Aside, Content, Main } = Styled;
return (
<Background>
<Container fullWidth={appView.fullWidth}>
<Hamburger
collapsed={!settingsStore.sidebarVisible}
onClick={settingsStore.toggleSidebar}
>
<Menu size="large" />
</Hamburger>
<Aside collapsed={!settingsStore.sidebarVisible}>
<Container>
<Aside>
<Sidebar />
</Aside>
<Content collapsed={!settingsStore.sidebarVisible} fullWidth={appView.fullWidth}>
<Fluid className="container-fluid">{children}</Fluid>
<Content>
<Main>{children}</Main>
</Content>
</Container>
<Global styles={GlobalStyles} />

View file

@ -1,100 +1,137 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { usePrefixedTranslation } from 'hooks';
import { useStore } from 'store';
import { Badge, HeaderFour } from 'components/base';
import { PUBLIC_URL } from '../../config';
import { Waypoints, Activity, ArrowLeftRight, Zap } from 'lucide-react';
const Styled = {
NavHeader: styled(HeaderFour)`
padding: 44px 14px 8px;
font-size: 10px;
line-height: 16px;
`,
Nav: styled.ul`
padding-left: 0;
margin-bottom: 0;
list-style: none;
`,
NavItem: styled.li`
font-size: ${props => props.theme.sizes.xs};
margin-right: -17px;
margin-bottom: 2px;
> span {
display: block;
height: 50px;
line-height: 50px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 12px;
border-left: 3px solid transparent;
color: ${props => props.theme.colors.offWhite};
border-radius: 8px;
color: ${props => props.theme.colors.gray};
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
letter-spacing: -0.01em;
&:hover {
text-decoration: none;
border-left: 3px solid ${props => props.theme.colors.pink};
background-color: ${props => props.theme.colors.overlay};
color: ${props => props.theme.colors.white};
background-color: rgba(255, 255, 255, 0.06);
}
}
&.active > span {
border-left: 3px solid ${props => props.theme.colors.offWhite};
background-color: ${props => props.theme.colors.blue};
&:hover {
border-left: 3px solid ${props => props.theme.colors.pink};
}
color: ${props => props.theme.colors.white};
background-color: rgba(255, 255, 255, 0.08);
}
`,
NavIcon: styled.span`
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.7;
`,
NavLabel: styled.span`
flex: 1;
`,
NavBadge: styled.span`
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: rgba(99, 102, 241, 0.15);
color: ${props => props.theme.colors.iris};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
};
const NavItem: React.FC<{
interface NavItemProps {
page: string;
label: string;
icon: React.ReactNode;
badge?: string;
active?: boolean;
onClick: () => void;
}> = observer(({ page, badge, onClick }) => {
const { l } = usePrefixedTranslation('cmps.layout.NavMenu');
const { router } = useStore();
const className = router.location.pathname.startsWith(`${PUBLIC_URL}/${page}`)
? 'active'
: '';
}
return (
<Styled.NavItem className={className}>
<span onClick={onClick}>
{l(page)}
{badge && (
<sup>
<Badge muted>{badge}</Badge>
</sup>
)}
</span>
</Styled.NavItem>
);
});
const NavItem: React.FC<NavItemProps> = observer(
({ page, label, icon, badge, active, onClick }) => {
const { router } = useStore();
const isActive =
active !== undefined
? active
: router.location.pathname.startsWith(`${PUBLIC_URL}/${page}`);
const className = isActive ? 'active' : '';
return (
<Styled.NavItem className={className}>
<span onClick={onClick}>
<Styled.NavIcon>{icon}</Styled.NavIcon>
<Styled.NavLabel>{label}</Styled.NavLabel>
{badge && <Styled.NavBadge>{badge}</Styled.NavBadge>}
</span>
</Styled.NavItem>
);
},
);
const ICON_SIZE = 16;
const ICON_STROKE = 1.5;
const NavMenu: React.FC = () => {
const { l } = usePrefixedTranslation('cmps.layout.NavMenu');
const { appView } = useStore();
const { appView, router, nodeConnectionStore } = useStore();
const onHome = router.location.pathname.startsWith(`${PUBLIC_URL}/home`);
const myNodeLabel = nodeConnectionStore.hasMultipleConnected ? 'My Nodes' : 'My Node';
const { NavHeader, Nav } = Styled;
const { Nav } = Styled;
return (
<>
<Nav>
<NavItem page="home" onClick={appView.goToHome} />
<NavItem page="settings" onClick={appView.goToSettings} />
</Nav>
<NavHeader>{l('liquidityHeader')}</NavHeader>
<Nav>
<NavItem page="loop" onClick={appView.goToLoop} />
<NavItem page="history" onClick={appView.goToHistory} />
<NavItem page="pool" badge={l('common.preview')} onClick={appView.goToPool} />
</Nav>
<NavHeader>{l('connectHeader')}</NavHeader>
<Nav>
<NavItem page="connect" badge={l('common.beta')} onClick={appView.goToConnect} />
</Nav>
</>
<Nav>
<NavItem
page="home"
label="Network"
active={onHome && appView.graphViewMode === 'network'}
icon={<Waypoints size={ICON_SIZE} strokeWidth={ICON_STROKE} />}
onClick={appView.goToNetwork}
/>
<NavItem
page="home"
label={myNodeLabel}
active={onHome && appView.graphViewMode === 'mynode'}
icon={<Zap size={ICON_SIZE} strokeWidth={ICON_STROKE} />}
onClick={appView.goToMyNode}
/>
<NavItem
page="history"
label="Activity"
icon={<Activity size={ICON_SIZE} strokeWidth={ICON_STROKE} />}
onClick={appView.goToHistory}
/>
<NavItem
page="loop"
label="Liquidity"
icon={<ArrowLeftRight size={ICON_SIZE} strokeWidth={ICON_STROKE} />}
onClick={appView.goToLoop}
/>
</Nav>
);
};

View file

@ -0,0 +1,505 @@
import React, { useState, useEffect, useRef } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { useStore } from 'store';
import {
ChevronDown,
Plus,
Check,
Trash2,
Loader2,
Wifi,
WifiOff,
KeyRound,
} from 'lucide-react';
import AddNodeModal from 'components/common/AddNodeModal';
const NODE_COLORS = [
'linear-gradient(135deg, #6366f1, #8b5cf6)',
'linear-gradient(135deg, #0ea5e9, #6366f1)',
'linear-gradient(135deg, #f59e0b, #ef4444)',
'linear-gradient(135deg, #22c55e, #0ea5e9)',
'linear-gradient(135deg, #ec4899, #8b5cf6)',
];
const ReconnectPrompt: React.FC<{
nodeId: string;
onReconnect: (id: string, password: string) => void;
onCancel: () => void;
}> = ({ nodeId, onReconnect, onCancel }) => {
const [password, setPassword] = useState('');
return (
<Styled.ReconnectBox onClick={e => e.stopPropagation()}>
<Styled.ReconnectLabel>Enter password to reconnect</Styled.ReconnectLabel>
<Styled.ReconnectInput
type="password"
placeholder="Encryption password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && password.trim()) onReconnect(nodeId, password);
}}
autoFocus
/>
<Styled.ReconnectActions>
<Styled.ReconnectBtn onClick={onCancel}>Cancel</Styled.ReconnectBtn>
<Styled.ReconnectBtnPrimary
onClick={() => password.trim() && onReconnect(nodeId, password)}
disabled={!password.trim()}
>
Connect
</Styled.ReconnectBtnPrimary>
</Styled.ReconnectActions>
</Styled.ReconnectBox>
);
};
const NodePicker: React.FC = () => {
const { nodeStore, nodeConnectionStore } = useStore();
const alias = nodeStore.alias || 'Lightning Node';
const initial = alias.charAt(0).toUpperCase();
const [open, setOpen] = useState(false);
const [showAddNode, setShowAddNode] = useState(false);
const [reconnectingId, setReconnectingId] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const allNodes = nodeConnectionStore.allNodes;
const activeLeftId = nodeConnectionStore.activeLeftId;
const activeRightId = nodeConnectionStore.activeRightId;
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
setReconnectingId(null);
}
};
if (open) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const handleDeleteNode = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
nodeConnectionStore.removeNode(nodeId);
};
const handleNodeClick = (nodeId: string) => {
const node = nodeConnectionStore.connections.get(nodeId);
if (!node) return;
if (node.connected) {
if (activeLeftId === nodeId) return;
if (activeRightId === nodeId) return;
if (!activeRightId || activeRightId === 'primary') {
nodeConnectionStore.setActiveRight(nodeId);
} else {
nodeConnectionStore.setActiveLeft(nodeId);
}
} else if (node.type === 'lnc' && !node.connecting) {
setReconnectingId(nodeId);
}
};
const handleReconnect = async (nodeId: string, password: string) => {
setReconnectingId(null);
try {
await nodeConnectionStore.reconnectNode(nodeId, password);
} catch {
// error is displayed on the node item
}
};
const {
Wrapper,
Button,
NodeIcon,
NodeName,
Chevron,
Dropdown,
DropdownLabel,
NodeItem,
NodeItemIcon,
NodeItemName,
NodeItemCheck,
NodeItemDelete,
NodeItemStatus,
AddNodeBtn,
} = Styled;
return (
<Wrapper ref={dropdownRef}>
<Button onClick={() => setOpen(!open)}>
<NodeIcon>{initial}</NodeIcon>
<NodeName>{alias}</NodeName>
<Chevron open={open}>
<ChevronDown size={14} strokeWidth={1.5} />
</Chevron>
</Button>
{open && (
<Dropdown>
<DropdownLabel>Primary Node</DropdownLabel>
<NodeItem active={activeLeftId === 'primary'}>
<NodeItemIcon color={NODE_COLORS[0]} style={{ background: NODE_COLORS[0] }}>
{initial}
</NodeItemIcon>
<NodeItemName>{alias}</NodeItemName>
<NodeItemStatus online>
<Wifi size={10} />
</NodeItemStatus>
{activeLeftId === 'primary' && (
<NodeItemCheck>
<Check size={14} />
</NodeItemCheck>
)}
</NodeItem>
{allNodes.filter(n => n.id !== 'primary').length > 0 && (
<>
<DropdownLabel>Additional Nodes</DropdownLabel>
{allNodes
.filter(n => n.id !== 'primary')
.map((node, i) => (
<React.Fragment key={node.id}>
<NodeItem
active={activeLeftId === node.id || activeRightId === node.id}
onClick={() => handleNodeClick(node.id)}
>
<NodeItemIcon
style={{
background: NODE_COLORS[(i + 1) % NODE_COLORS.length],
}}
>
{node.initial}
</NodeItemIcon>
<NodeItemName>
{node.displayName}
{node.error && (
<NodeItemError title={node.error}>!</NodeItemError>
)}
</NodeItemName>
{node.connecting ? (
<NodeItemStatus>
<SpinnerIcon size={10} />
</NodeItemStatus>
) : (
<NodeItemStatus online={node.connected}>
{node.connected ? <Wifi size={10} /> : <WifiOff size={10} />}
</NodeItemStatus>
)}
{!node.connected && !node.connecting && node.type === 'lnc' && (
<NodeItemReconnect
onClick={e => {
e.stopPropagation();
setReconnectingId(node.id);
}}
title="Reconnect"
>
<KeyRound size={11} />
</NodeItemReconnect>
)}
{(activeLeftId === node.id || activeRightId === node.id) && (
<NodeItemCheck>
<Check size={14} />
</NodeItemCheck>
)}
<NodeItemDelete
onClick={(e: React.MouseEvent) => handleDeleteNode(e, node.id)}
>
<Trash2 size={12} />
</NodeItemDelete>
</NodeItem>
{reconnectingId === node.id && (
<ReconnectPrompt
nodeId={node.id}
onReconnect={handleReconnect}
onCancel={() => setReconnectingId(null)}
/>
)}
</React.Fragment>
))}
</>
)}
<AddNodeBtn
onClick={() => {
setOpen(false);
setShowAddNode(true);
}}
>
<Plus size={14} />
Add Node
</AddNodeBtn>
</Dropdown>
)}
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
</Wrapper>
);
};
export default observer(NodePicker);
const SpinnerIcon = styled(Loader2)`
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const NodeItemError = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-size: 9px;
font-weight: 700;
margin-left: 4px;
flex-shrink: 0;
`;
const NodeItemReconnect = styled.button`
background: none;
border: none;
padding: 2px;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: #a78bfa;
background: rgba(139, 92, 246, 0.1);
}
`;
const Styled = {
Wrapper: styled.div`
padding: 16px 14px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
`,
Button: styled.button`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: ${(props: any) => props.theme.colors.white};
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
letter-spacing: -0.02em;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
`,
NodeIcon: styled.div`
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(
135deg,
${(props: any) => props.theme.colors.iris} 0%,
${(props: any) => props.theme.colors.purple} 100%
);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
color: white;
flex-shrink: 0;
`,
NodeName: styled.span`
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
Chevron: styled.span<{ open?: boolean }>`
opacity: 0.4;
display: flex;
align-items: center;
transition: transform 0.15s;
transform: ${p => (p.open ? 'rotate(180deg)' : 'none')};
`,
Dropdown: styled.div`
position: absolute;
top: calc(100% + 4px);
left: 10px;
right: 10px;
background: #120e24;
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
`,
DropdownLabel: styled.div`
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #475569;
padding: 10px 14px 4px;
`,
NodeItem: styled.button<{ active?: boolean }>`
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border: none;
background: ${p => (p.active ? 'rgba(139, 92, 246, 0.08)' : 'transparent')};
color: #e2e8f0;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background 0.1s;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
`,
NodeItemIcon: styled.div<{ color?: string }>`
width: 26px;
height: 26px;
border-radius: 6px;
background: ${p => p.color || 'rgba(139, 92, 246, 0.15)'};
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: white;
flex-shrink: 0;
`,
NodeItemName: styled.span`
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
`,
NodeItemCheck: styled.span`
color: #a78bfa;
display: flex;
align-items: center;
flex-shrink: 0;
`,
NodeItemDelete: styled.span`
color: #475569;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
&:hover {
color: #ef4444;
}
button:hover > & {
opacity: 1;
}
`,
NodeItemStatus: styled.span<{ online?: boolean }>`
display: flex;
align-items: center;
color: ${p => (p.online ? '#22c55e' : '#64748b')};
flex-shrink: 0;
`,
AddNodeBtn: styled.button`
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: transparent;
color: #a78bfa;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
&:hover {
background: rgba(139, 92, 246, 0.06);
}
`,
ReconnectBox: styled.div`
padding: 8px 14px 12px;
background: rgba(139, 92, 246, 0.04);
border-top: 1px solid rgba(139, 92, 246, 0.08);
`,
ReconnectLabel: styled.div`
font-size: 11px;
color: #94a3b8;
margin-bottom: 6px;
`,
ReconnectInput: styled.input`
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(139, 92, 246, 0.15);
border-radius: 6px;
padding: 7px 10px;
color: #e2e8f0;
font-size: 12px;
outline: none;
box-sizing: border-box;
&::placeholder {
color: #475569;
}
&:focus {
border-color: rgba(139, 92, 246, 0.35);
}
`,
ReconnectActions: styled.div`
display: flex;
gap: 6px;
margin-top: 6px;
justify-content: flex-end;
`,
ReconnectBtn: styled.button`
padding: 5px 12px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: transparent;
color: #94a3b8;
font-size: 11px;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
`,
ReconnectBtnPrimary: styled.button<{ disabled?: boolean }>`
padding: 5px 12px;
border-radius: 6px;
border: none;
background: ${p =>
p.disabled ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.7)'};
color: white;
font-size: 11px;
font-weight: 500;
cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
&:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.9);
}
`,
};

View file

@ -1,22 +1,239 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import NodeStatus from 'components/NodeStatus';
import { useStore } from 'store';
import { BookOpen, Settings, Copy, Check } from 'lucide-react';
import NavMenu from './NavMenu';
import NodePicker from './NodePicker';
import OnboardingModal from '../tour/OnboardingModal';
const Styled = {
Wrapper: styled.div`
padding-top: 95px;
display: flex;
flex-direction: column;
height: 100%;
padding: 0;
`,
NavSection: styled.div`
flex: 1;
padding: 8px 10px;
overflow-y: auto;
`,
BottomSection: styled.div`
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 6px;
`,
OnboardingWidget: styled.button`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 8px;
background: rgba(99, 102, 241, 0.08);
color: ${props => props.theme.colors.offWhite};
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(99, 102, 241, 0.14);
border-color: rgba(99, 102, 241, 0.4);
}
`,
WidgetIcon: styled.span`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.8;
`,
WidgetText: styled.span`
flex: 1;
text-align: left;
`,
WidgetBadge: styled.span`
font-size: 10px;
font-weight: 600;
background: ${props => props.theme.colors.iris};
color: white;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.02em;
`,
NodeInfoButton: styled.button`
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: ${props => props.theme.colors.offWhite};
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
`,
NodeDot: styled.span<{ online?: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${props =>
props.online ? props.theme.colors.green : props.theme.colors.gray};
flex-shrink: 0;
`,
NodeLabel: styled.span`
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
SettingsIcon: styled.span`
display: flex;
align-items: center;
opacity: 0.4;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
`,
CopyNodeBtn: styled.button`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: 1px solid rgba(139, 92, 246, 0.12);
border-radius: 8px;
background: rgba(139, 92, 246, 0.05);
color: rgba(255, 255, 255, 0.5);
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
&:hover {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.25);
color: rgba(255, 255, 255, 0.7);
}
`,
CopyIcon: styled.span`
display: flex;
align-items: center;
flex-shrink: 0;
color: #a78bfa;
`,
PubkeyText: styled.span`
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
letter-spacing: 0.02em;
`,
};
const Sidebar: React.FC = () => {
const { Wrapper } = Styled;
const { nodeStore, appView } = useStore();
const alias = nodeStore.alias || 'My Node';
const pubkey = nodeStore.pubkey || '';
const [showOnboarding, setShowOnboarding] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopyNodeId = useCallback(async () => {
if (!pubkey) return;
try {
await navigator.clipboard.writeText(pubkey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
const el = document.createElement('textarea');
el.value = pubkey;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [pubkey]);
const {
Wrapper,
NavSection,
BottomSection,
OnboardingWidget,
WidgetIcon,
WidgetText,
WidgetBadge,
NodeInfoButton,
NodeDot,
NodeLabel,
SettingsIcon,
CopyNodeBtn,
CopyIcon,
PubkeyText,
} = Styled;
return (
<Wrapper>
<NodeStatus />
<NavMenu />
<NodePicker />
<NavSection>
<NavMenu />
</NavSection>
<BottomSection>
{pubkey && (
<CopyNodeBtn
onClick={handleCopyNodeId}
title="Copy your Node ID to share with peers"
>
<CopyIcon>
{copied ? (
<Check size={12} strokeWidth={2} />
) : (
<Copy size={12} strokeWidth={1.5} />
)}
</CopyIcon>
<PubkeyText>{copied ? 'Copied!' : pubkey}</PubkeyText>
</CopyNodeBtn>
)}
<OnboardingWidget onClick={() => setShowOnboarding(true)}>
<WidgetIcon>
<BookOpen size={14} strokeWidth={1.5} />
</WidgetIcon>
<WidgetText>Get Started</WidgetText>
<WidgetBadge>Guide</WidgetBadge>
</OnboardingWidget>
<NodeInfoButton onClick={appView.goToSettings}>
<NodeDot online={true} />
<NodeLabel>{alias}</NodeLabel>
<SettingsIcon>
<Settings size={14} strokeWidth={1.5} />
</SettingsIcon>
</NodeInfoButton>
</BottomSection>
{showOnboarding && <OnboardingModal onClose={() => setShowOnboarding(false)} />}
</Wrapper>
);
};
export default Sidebar;
export default observer(Sidebar);

View file

@ -28,11 +28,11 @@ const Styled = {
}
`,
Section: styled.div`
height: 4px;
height: 3px;
border-radius: 2px;
`,
Gap: styled.div`
width: 10px;
width: 4px;
background-color: transparent;
`,
};

View file

@ -7,7 +7,7 @@ import ChannelRow, { ChannelRowHeader, ROW_HEIGHT } from './ChannelRow';
const Styled = {
Wrapper: styled.section`
margin: 50px 0;
margin: 24px 0 0;
`,
ListContainer: styled.div`
/**
@ -36,12 +36,18 @@ const ChannelList: React.FC = () => {
<ChannelRowHeader />
<ListContainer>
<AutoSizer disableHeight>
{({ width }) => (
{({ width }: { width: number }) => (
<WindowScroller>
{({ height, isScrolling, onChildScroll, scrollTop, registerChild }) => (
{({
height,
isScrolling,
onChildScroll,
scrollTop,
registerChild,
}: any) => (
<Observer>
{() => (
<div ref={ref => ref && registerChild(ref)}>
<div ref={(ref: any) => ref && registerChild(ref)}>
<List
autoHeight
height={height}
@ -49,12 +55,10 @@ const ChannelList: React.FC = () => {
onScroll={onChildScroll}
rowCount={buildSwapView.channels.length}
rowHeight={ROW_HEIGHT}
rowRenderer={({ index, key, style }) => (
<ChannelRow
key={key}
style={style}
channel={buildSwapView.channels[index]}
/>
rowRenderer={({ index, key, style }: any) => (
<div key={key} style={style}>
<ChannelRow channel={buildSwapView.channels[index]} />
</div>
)}
scrollTop={scrollTop}
width={width}

View file

@ -17,7 +17,7 @@ import ChannelIcon from './ChannelIcon';
* the virtualized list requires each row to have a specified
* height. Defining a const here because it is used in multiple places
*/
export const ROW_HEIGHT = 60;
export const ROW_HEIGHT = 44;
const BaseColumn = styled(Column)`
white-space: nowrap;
@ -28,8 +28,10 @@ const BaseColumn = styled(Column)`
const Styled = {
Row: styled(Row)<{ dimmed?: boolean; selectable?: boolean }>`
border-bottom: 0.5px solid ${props => props.theme.colors.darkGray};
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
opacity: ${props => (props.dimmed ? '0.4' : '1')};
font-size: 13px;
transition: background 0.1s ease;
&:last-child {
border-bottom-width: 0;
@ -40,7 +42,7 @@ const Styled = {
`
&:hover {
cursor: pointer;
background-color: ${props.theme.colors.overlay};
background-color: rgba(255, 255, 255, 0.03);
}
`}
`,

View file

@ -10,25 +10,24 @@ import SelectedChannels from './SelectedChannels';
const Styled = {
Wrapper: styled.section`
margin: 50px 0;
margin: 20px 0 8px;
`,
Actions: styled.div`
display: flex;
align-items: center;
margin-top: -15px;
gap: 12px;
`,
ActionBar: styled.div`
display: flex;
align-items: center;
min-width: 595px;
padding: 15px;
background-color: ${props => props.theme.colors.darkBlue};
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.5);
border-radius: 35px;
gap: 8px;
padding: 8px 14px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
> button {
margin-left: 10px;
background-color: ${props => props.theme.colors.overlay};
margin-left: 0;
}
`,
CloseIcon: styled(Close)`

View file

@ -28,17 +28,19 @@ const LoopHistory: React.FC = () => {
<>
{swapStore.lastTwoSwaps.length === 0 && <Small>{l('emptyMsg')}</Small>}
{swapStore.lastTwoSwaps.map(swap => (
<Row key={swap.id}>
<Column cols={6}>
<SwapDot swap={swap} />
<SmallText>{swap.createdOn.toLocaleDateString()}</SmallText>
</Column>
<RightColumn cols={6}>
<SmallText>
<Unit sats={swap.amount} />
</SmallText>
</RightColumn>
</Row>
<div key={swap.id}>
<Row>
<Column cols={6}>
<SwapDot swap={swap} />
<SmallText>{swap.createdOn.toLocaleDateString()}</SmallText>
</Column>
<RightColumn cols={6}>
<SmallText>
<Unit sats={swap.amount} />
</SmallText>
</RightColumn>
</Row>
</div>
))}
</>
);

View file

@ -1,70 +1,402 @@
import React from 'react';
import React, { useState } from 'react';
import { observer } from 'mobx-react-lite';
import styled from '@emotion/styled';
import { usePrefixedTranslation } from 'hooks';
import { useStore } from 'store';
import { Badge } from 'components/base';
import PageHeader from 'components/common/PageHeader';
import SubServerRequired from 'components/common/SubServerRequired';
import {
ArrowLeftRight,
ArrowDownLeft,
ArrowUpRight,
AlertTriangle,
TrendingUp,
TrendingDown,
Plus,
Droplets,
} from 'lucide-react';
import Unit from 'components/common/Unit';
import TransactionModal from 'components/common/TransactionModal';
import OpenChannelModal from 'components/common/OpenChannelModal';
import ChannelList from './ChannelList';
import LoopActions from './LoopActions';
import LoopTiles from './LoopTiles';
import ProcessingSwaps from './processing/ProcessingSwaps';
import SidecarWizard from './sidecar/SidecarWizard';
import SwapWizard from './swap/SwapWizard';
import ProcessingSwaps from './processing/ProcessingSwaps';
const Styled = {
PageWrap: styled.div`
padding: 40px 0;
padding: 28px 36px;
min-height: 100vh;
background: #000000;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`,
Header: styled.div`
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
margin-bottom: 28px;
`,
Title: styled.h2`
font-size: 22px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin: 0;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: -0.02em;
`,
LoopUnavailable: styled.div`
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
margin-bottom: 20px;
`,
TileRow: styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
`,
Tile: styled.div`
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 20px 24px;
`,
TileLabel: styled.div`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
`,
TileValue: styled.div`
font-size: 26px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
`,
BalanceBar: styled.div`
margin-top: 10px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
display: flex;
`,
BarSegment: styled.div<{ width: number; color: string }>`
height: 100%;
width: ${p => p.width}%;
background: ${p => p.color};
transition: width 0.3s;
`,
ActionRow: styled.div`
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
`,
ActionBtn: styled.button<{ variant?: 'primary' }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
border: none;
${p =>
p.variant === 'primary'
? `
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
&:hover { background: rgba(255, 255, 255, 0.14); }
`
: `
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06);
&:hover {
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.8);
}
`}
`,
HealthNotice: styled.div<{ severity: 'warning' | 'info' }>`
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-radius: 12px;
margin-bottom: 20px;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.5);
${p =>
p.severity === 'warning'
? `
background: rgba(251, 191, 36, 0.04);
border: 1px solid rgba(251, 191, 36, 0.1);
`
: `
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
`}
`,
NoticeIcon: styled.div<{ severity: 'warning' | 'info' }>`
flex-shrink: 0;
margin-top: 1px;
color: ${p => (p.severity === 'warning' ? '#fbbf24' : 'rgba(255, 255, 255, 0.3)')};
`,
NoticeText: styled.div`
flex: 1;
`,
NoticeLink: styled.span`
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
font-weight: 500;
text-decoration: underline;
text-decoration-color: rgba(255, 255, 255, 0.2);
text-underline-offset: 2px;
&:hover {
text-decoration-color: rgba(255, 255, 255, 0.5);
}
`,
};
const LoopPage: React.FC = () => {
const { l } = usePrefixedTranslation('cmps.loop.LoopPage');
const { appView, buildSwapView, channelStore, nodeStore, subServerStore } = useStore();
const [showOpenChannel, setShowOpenChannel] = useState(false);
const [showReceive, setShowReceive] = useState(false);
const [showSend, setShowSend] = useState(false);
const loopRunning =
subServerStore.subServers.loop.running && !subServerStore.subServers.loop.error;
const inbound = channelStore.totalInbound.toNumber();
const outbound = channelStore.totalOutbound.toNumber();
const total = inbound + outbound;
const inPct = total > 0 ? (inbound / total) * 100 : 50;
const outPct = total > 0 ? (outbound / total) * 100 : 50;
const hasChannels = channelStore.sortedChannels.length > 0;
const ratio = total > 0 ? outbound / total : 0.5;
const isOutboundHeavy = ratio > 0.75;
const isInboundHeavy = ratio < 0.25;
const isUnbalanced = isOutboundHeavy || isInboundHeavy;
const handleAddInbound = () => {
if (loopRunning) {
buildSwapView.startSwap();
}
};
const handleAddOutbound = () => {
setShowOpenChannel(true);
};
const {
appView,
buildSwapView,
registerSidecarView,
channelStore,
nodeStore,
subServerStore,
} = useStore();
PageWrap,
Header,
Title,
LoopUnavailable,
TileRow,
Tile,
TileLabel,
TileValue,
BalanceBar,
BarSegment,
ActionRow,
ActionBtn,
HealthNotice,
NoticeIcon,
NoticeText,
NoticeLink,
} = Styled;
const title = (
<>
{l('pageTitle')}
{nodeStore.network !== 'mainnet' && (
<div>
<Badge>{nodeStore.network}</Badge>
</div>
)}
</>
);
const { PageWrap } = Styled;
return (
<SubServerRequired status={subServerStore.subServers.loop}>
<PageWrap>
{appView.processingSwapsVisible ? (
<ProcessingSwaps />
) : buildSwapView.showWizard ? (
<SwapWizard />
) : registerSidecarView.showWizard ? (
<SidecarWizard />
) : (
<>
<PageHeader
title={title}
onHelpClick={appView.showTour}
onExportClick={channelStore.exportChannels}
/>
<LoopTiles />
<LoopActions />
</>
)}
<ChannelList />
</PageWrap>
</SubServerRequired>
<PageWrap>
<Header>
<Title>
<ArrowLeftRight size={18} />
{l('pageTitle')}
{nodeStore.network !== 'mainnet' && <Badge>{nodeStore.network}</Badge>}
</Title>
</Header>
{appView.processingSwapsVisible ? (
<ProcessingSwaps />
) : buildSwapView.showWizard ? (
<SwapWizard />
) : (
<>
{hasChannels && (
<>
<TileRow>
<Tile>
<TileLabel>
<ArrowDownLeft size={13} color="#22c55e" />
Inbound
</TileLabel>
<TileValue>
<Unit sats={channelStore.totalInbound} />
</TileValue>
</Tile>
<Tile>
<TileLabel>
<ArrowUpRight size={13} color="#a78bfa" />
Outbound
</TileLabel>
<TileValue>
<Unit sats={channelStore.totalOutbound} />
</TileValue>
</Tile>
</TileRow>
<BalanceBar>
<BarSegment width={inPct} color="#34d399" />
<BarSegment width={outPct} color="rgba(255, 255, 255, 0.3)" />
</BalanceBar>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.2)',
marginTop: '6px',
marginBottom: '18px',
letterSpacing: '0.02em',
}}
>
<span>Inbound {inPct.toFixed(0)}%</span>
<span>Outbound {outPct.toFixed(0)}%</span>
</div>
{isUnbalanced && (
<HealthNotice severity="warning">
<NoticeIcon severity="warning">
<AlertTriangle size={16} />
</NoticeIcon>
<NoticeText>
{isOutboundHeavy ? (
<>
Your liquidity is outbound-heavy ({outPct.toFixed(0)}% outbound).
You may have trouble receiving payments.{' '}
{loopRunning ? (
<NoticeLink onClick={handleAddInbound}>
Add inbound liquidity
</NoticeLink>
) : (
<>
Open a channel where a peer sends liquidity to you, or ask a
well-connected node to open a channel to your node.
</>
)}
</>
) : (
<>
Your liquidity is inbound-heavy ({inPct.toFixed(0)}% inbound). You
may have trouble sending payments.{' '}
<NoticeLink onClick={handleAddOutbound}>
Open a new channel
</NoticeLink>{' '}
to add outbound capacity.
</>
)}
</NoticeText>
</HealthNotice>
)}
{!isUnbalanced && hasChannels && (
<HealthNotice severity="info">
<NoticeIcon severity="info">
<Droplets size={16} />
</NoticeIcon>
<NoticeText>
Your liquidity is well-balanced. You can send and receive payments
without issues.
</NoticeText>
</HealthNotice>
)}
</>
)}
{!hasChannels && (
<HealthNotice severity="info">
<NoticeIcon severity="info">
<AlertTriangle size={16} />
</NoticeIcon>
<NoticeText>
No channels yet.{' '}
<NoticeLink onClick={handleAddOutbound}>
Open your first channel
</NoticeLink>{' '}
to start sending and receiving Lightning payments.
</NoticeText>
</HealthNotice>
)}
{!loopRunning && (
<LoopUnavailable>
Loop swap service is unavailable. You can still manage channels and
liquidity manually.
</LoopUnavailable>
)}
<ActionRow>
<ActionBtn variant="primary" onClick={() => setShowOpenChannel(true)}>
<Plus size={14} />
Open Channel
</ActionBtn>
{loopRunning && (
<>
<ActionBtn onClick={handleAddInbound}>
<TrendingDown size={14} />
Add Inbound
</ActionBtn>
<ActionBtn onClick={handleAddOutbound}>
<TrendingUp size={14} />
Add Outbound
</ActionBtn>
</>
)}
<ActionBtn onClick={() => setShowReceive(true)}>
<ArrowDownLeft size={14} />
Receive
</ActionBtn>
<ActionBtn onClick={() => setShowSend(true)}>
<ArrowUpRight size={14} />
Send
</ActionBtn>
</ActionRow>
</>
)}
<ChannelList />
{showOpenChannel && <OpenChannelModal onClose={() => setShowOpenChannel(false)} />}
{showReceive && (
<TransactionModal direction="receive" onClose={() => setShowReceive(false)} />
)}
{showSend && (
<TransactionModal direction="send" onClose={() => setShowSend(false)} />
)}
</PageWrap>
);
};

View file

@ -10,7 +10,8 @@ import LoopHistory from './LoopHistory';
const Styled = {
TileSection: styled.section`
margin-top: 90px;
margin-top: 16px;
margin-bottom: 16px;
`,
};

View file

@ -54,9 +54,9 @@ const ProcessingSwaps: React.FC = () => {
</Tip>
</Header>
<Content data-tour="processing-swaps">
{swapStore.processingSwaps.map(swap => (
<ProcessingSwapRow key={swap.id} swap={swap} />
))}
{swapStore.processingSwaps.map(swap =>
React.createElement(ProcessingSwapRow, { key: swap.id, swap }),
)}
{swapStore.processingSwaps.length === 0 && (
<Complete>
<ConfirmAnimation animationData={confirmJson} />

View file

@ -13,7 +13,7 @@ import OrderListSection from './OrderListSection';
const Styled = {
Wrapper: styled.div`
padding: 40px 0 0;
padding: 20px 24px 0;
height: 100%;
display: flex;
flex-direction: column;

View file

@ -56,18 +56,16 @@ const BatchList: React.FC = () => {
<BatchRowHeader />
<Content>
<AutoSizer>
{({ width, height }) => (
{({ width, height }: { width: number; height: number }) => (
<Observer>
{() => (
<List
rowCount={batchStore.sortedBatches.length}
rowHeight={ROW_HEIGHT}
rowRenderer={({ index, key, style }) => (
<BatchRow
key={key}
style={style}
batch={batchStore.sortedBatches[index]}
/>
rowRenderer={({ index, key, style }: any) => (
<div key={key} style={style}>
<BatchRow batch={batchStore.sortedBatches[index]} />
</div>
)}
width={width}
height={height}

View file

@ -153,15 +153,15 @@ const OrdersList: React.FC = () => {
</tr>
</thead>
<tbody>
{orderListView.orders.map(order => (
<OrderRow
key={order.nonce}
order={order}
selected={order.nonce === orderListView.selectedNonce}
onClick={orderListView.setChosenNonce}
onCancel={orderStore.cancelOrder}
/>
))}
{orderListView.orders.map(order =>
React.createElement(OrderRow, {
key: order.nonce,
order,
selected: order.nonce === orderListView.selectedNonce,
onClick: orderListView.setChosenNonce,
onCancel: orderStore.cancelOrder,
}),
)}
</tbody>
</Table>
{orderListView.orders.length === 0 && (

View file

@ -12,7 +12,8 @@ import SettingItem from './SettingItem';
const Styled = {
Wrapper: styled.section``,
Content: styled.div`
margin: 100px 50px;
margin: 24px 0;
padding: 0 24px;
`,
};

View file

@ -6,25 +6,30 @@ const Styled = {
Wrapper: styled.div`
display: flex;
align-items: center;
height: 80px;
height: 48px;
cursor: pointer;
border-bottom: 0.5px solid ${props => props.theme.colors.lightBlue};
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 6px;
padding: 0 12px;
transition: background 0.15s ease;
&:last-child {
border-bottom-width: 0;
}
&:hover {
opacity: 0.8;
background: rgba(255, 255, 255, 0.04);
}
`,
Name: styled.span`
flex: 1;
font-size: ${props => props.theme.sizes.l};
font-size: ${props => props.theme.sizes.s};
font-weight: 500;
`,
Value: styled.span`
color: ${props => props.theme.colors.gray};
margin-right: 20px;
font-size: ${props => props.theme.sizes.xs};
margin-right: 12px;
`,
};

View file

@ -9,7 +9,9 @@ import UnitSettings from './UnitSettings';
const Styled = {
Wrapper: styled.div`
padding: 40px 0;
padding: 24px 32px;
min-height: 100vh;
background: radial-gradient(ellipse at 50% 50%, #0f1218 0%, #090b10 100%);
`,
};

View file

@ -1,53 +1,52 @@
import React from 'react';
import { Theme, ThemeProvider as EmotionThemeProvider } from '@emotion/react';
// use the bootstrap font-families as a fallback while custom fonts are being downloaded
const fallbackFont =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'";
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
const theme: Theme = {
fonts: {
open: {
light: `'OpenSans Light', ${fallbackFont}`,
regular: `'OpenSans Regular', ${fallbackFont}`,
semiBold: `'OpenSans SemiBold', ${fallbackFont}`,
bold: `'OpenSans Bold', ${fallbackFont}`,
extraBold: `'OpenSans ExtraBold', ${fallbackFont}`,
light: `${fallbackFont}`,
regular: `${fallbackFont}`,
semiBold: `${fallbackFont}`,
bold: `${fallbackFont}`,
extraBold: `${fallbackFont}`,
},
work: {
light: `'WorkSans Light', ${fallbackFont}`,
medium: `'WorkSans Medium', ${fallbackFont}`,
semiBold: `'WorkSans SemiBold', ${fallbackFont}`,
light: `${fallbackFont}`,
medium: `${fallbackFont}`,
semiBold: `${fallbackFont}`,
},
},
sizes: {
xxs: '11px',
xs: '14px',
s: '16px',
m: '18px',
l: '22px',
xl: '27px',
xxl: '45px',
xs: '13px',
s: '14px',
m: '15px',
l: '18px',
xl: '24px',
xxl: '36px',
},
colors: {
blue: '#252f4a',
darkBlue: '#212133',
gray: '#848a99',
darkGray: '#6b6969ef',
blue: '#131620',
darkBlue: '#0d1017',
gray: '#6b7280',
darkGray: '#4b5563',
white: '#ffffff',
offWhite: '#f5f5f5',
pink: '#f5406e',
offWhite: '#e5e7eb',
pink: '#f43f5e',
green: '#10B981',
gold: '#F59E0B',
purple: '#57038d',
overlay: 'rgba(245,245,245,0.04)',
gradient: 'linear-gradient(325.53deg, #252F4A 0%, #46547B 100%);',
lightBlue: '#384770',
paleBlue: '#2E3A5C',
purple: '#8b5cf6',
overlay: 'rgba(255,255,255,0.04)',
gradient: 'linear-gradient(135deg, #131620 0%, #1e2433 100%)',
lightBlue: '#1e2433',
paleBlue: '#252d3d',
lightningRed: '#EF4444',
lightningGray: '#B9BDC5',
lightningNavy: '#1D253A',
iris: '#5D5FEF',
lightningGray: '#9ca3af',
lightningNavy: '#0d1017',
iris: '#6366f1',
},
breakpoints: {
s: 'min-width: 576px',

View file

@ -0,0 +1,294 @@
import React, { useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';
import { useStore } from 'store';
import { Zap, Waypoints, ArrowLeftRight, Compass, Rocket, X } from 'lucide-react';
const STEP_ICON_SIZE = 22;
const STEP_ICON_STROKE = 1.5;
const STEPS = [
{
id: 'welcome',
icon: <Zap size={STEP_ICON_SIZE} strokeWidth={STEP_ICON_STROKE} />,
title: 'Welcome to Lightning Terminal',
body:
'Lightning Terminal gives you a visual interface to manage your Lightning node, channels, and liquidity — all in one place.',
detail:
'Whether you want to monitor channel health, rebalance liquidity, or open new channels, LiT makes it simple.',
},
{
id: 'graph',
icon: <Waypoints size={STEP_ICON_SIZE} strokeWidth={STEP_ICON_STROKE} />,
title: 'Your Node Graph',
body:
'The Graph tab shows a live 3D visualization of your node and every channel connected to it.',
detail:
'Node size reflects capacity. Hover over any node or channel to see details like balance, capacity, and status. The graph rotates slowly and pauses when you interact.',
},
{
id: 'liquidity',
icon: <ArrowLeftRight size={STEP_ICON_SIZE} strokeWidth={STEP_ICON_STROKE} />,
title: 'Manage Liquidity',
body:
'The Liquidity tab lets you rebalance your channels using Loop — moving funds on-chain or off-chain to optimize your routing.',
detail:
'Use "Rebalance" to perform Loop In (buy inbound) or Loop Out (move funds on-chain). Select channels and choose the direction that fits your needs.',
},
{
id: 'explore',
icon: <Compass size={STEP_ICON_SIZE} strokeWidth={STEP_ICON_STROKE} />,
title: 'Explore the Network',
body: 'Browse your channels, connected peers, and assets in the Explore tab.',
detail:
'See all your channels at a glance with capacity, status, and balance info. Use this to identify which channels need attention.',
},
{
id: 'ready',
icon: <Rocket size={STEP_ICON_SIZE} strokeWidth={STEP_ICON_STROKE} />,
title: "You're Ready",
body:
"That's all you need to get started. You can revisit this guide anytime from the sidebar.",
detail:
'Check out the docs at docs.lightning.engineering for deeper dives into Loop, Pool, and channel management.',
},
];
const Overlay = styled.div`
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
`;
const Modal = styled.div`
width: 520px;
max-width: 90vw;
max-height: 85vh;
background: #141720;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
overflow: hidden;
display: flex;
flex-direction: column;
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
`;
const StepIndicator = styled.div`
display: flex;
gap: 6px;
`;
const Dot = styled.button<{ active: boolean; visited: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
background: ${p =>
p.active ? '#6366f1' : p.visited ? 'rgba(99,102,241,0.4)' : 'rgba(255,255,255,0.12)'};
transform: ${p => (p.active ? 'scale(1.3)' : 'scale(1)')};
&:hover {
background: ${p => (p.active ? '#6366f1' : 'rgba(99,102,241,0.6)')};
}
`;
const CloseBtn = styled.button`
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.15s ease;
line-height: 1;
&:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.06);
}
`;
const Body = styled.div`
padding: 32px 24px 24px;
flex: 1;
`;
const StepIcon = styled.div`
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(139, 92, 246, 0.2);
border: 1px solid rgba(139, 92, 246, 0.35);
color: #a78bfa;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 20px;
`;
const Title = styled.h2`
font-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 600;
color: #fff;
margin: 0 0 12px;
letter-spacing: -0.02em;
`;
const BodyText = styled.p`
font-family: 'Inter', sans-serif;
font-size: 14px;
line-height: 1.65;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 12px;
`;
const Detail = styled.p`
font-family: 'Inter', sans-serif;
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.45);
margin: 0;
`;
const Footer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
`;
const SecondaryBtn = styled.button`
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.9);
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.04);
}
`;
const PrimaryBtn = styled.button`
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 8px 20px;
border-radius: 8px;
border: none;
background: #6366f1;
color: white;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #5355d4;
}
`;
const StepCount = styled.span`
font-family: 'Inter', sans-serif;
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
font-variant-numeric: tabular-nums;
`;
interface Props {
onClose: () => void;
}
const OnboardingModal: React.FC<Props> = ({ onClose }) => {
const [step, setStep] = useState(0);
const { settingsStore } = useStore();
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
const isFirst = step === 0;
const handleNext = useCallback(() => {
if (isLast) {
settingsStore.tourAutoShown = true;
onClose();
} else {
setStep(s => s + 1);
}
}, [isLast, onClose, settingsStore]);
const handleBack = useCallback(() => {
if (!isFirst) setStep(s => s - 1);
}, [isFirst]);
const handleSkip = useCallback(() => {
settingsStore.tourAutoShown = true;
onClose();
}, [onClose, settingsStore]);
return ReactDOM.createPortal(
<Overlay onClick={handleSkip}>
<Modal onClick={e => e.stopPropagation()}>
<Header>
<StepIndicator>
{STEPS.map((s, i) => (
<Dot
key={s.id}
active={i === step}
visited={i < step}
onClick={() => setStep(i)}
/>
))}
</StepIndicator>
<CloseBtn onClick={handleSkip}>
<X size={16} strokeWidth={1.5} />
</CloseBtn>
</Header>
<Body>
<StepIcon>{current.icon}</StepIcon>
<Title>{current.title}</Title>
<BodyText>{current.body}</BodyText>
<Detail>{current.detail}</Detail>
</Body>
<Footer>
<div>
{isFirst ? (
<SecondaryBtn onClick={handleSkip}>Skip</SecondaryBtn>
) : (
<SecondaryBtn onClick={handleBack}>Back</SecondaryBtn>
)}
</div>
<StepCount>
{step + 1} / {STEPS.length}
</StepCount>
<PrimaryBtn onClick={handleNext}>{isLast ? 'Get Started' : 'Next'}</PrimaryBtn>
</Footer>
</Modal>
</Overlay>,
document.body,
);
};
export default OnboardingModal;

View file

@ -123,12 +123,19 @@ const tourSteps: ReactourStep[] = [
const Styled = {
Tour: styled(Tour)`
&.reactour__helper {
border-radius: 10px;
border-radius: 12px;
background: ${props => props.theme.colors.lightBlue} !important;
color: ${props => props.theme.colors.offWhite} !important;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important;
}
[data-tour-elem='badge'] {
font-family: ${props => props.theme.fonts.open.regular};
font-size: ${props => props.theme.sizes.xxs};
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
background: ${props => props.theme.colors.iris} !important;
border-radius: 4px;
}
`,
};
@ -143,7 +150,7 @@ const TourHost: React.FC = () => {
steps={tourSteps}
isOpen={appView.tourVisible}
onRequestClose={appView.closeTour}
accentColor={theme.colors.pink}
accentColor={theme.colors.iris}
goToStep={appView.tourActiveStep}
getCurrentStep={appView.setTourActiveStep}
showNavigation={false}

View file

@ -1,7 +1,7 @@
{
"common.loop": "Loop",
"common.loopIn": "Loop In",
"common.loopOut": "Loop Out",
"common.loop": "Rebalance",
"common.loopIn": "Buy Inbound",
"common.loopOut": "Drain Outbound",
"common.sidecar": "Sidecar",
"common.blocks": "block",
"common.blocks_plural": "blocks",
@ -87,8 +87,8 @@
"cmps.connect.CustomSessionPage.advanced": "Advanced Options",
"cmps.connect.CustomSessionPage.proxy": "Proxy Server",
"cmps.connect.CustomSessionPage.proxyDesc": "Specify a custom Lightning Node Connect proxy server",
"cmps.connect.ConnectPage.pageTitle": "Lightning Node Connect",
"cmps.connect.ConnectPage.description": "Lightning Node Connect enables you to connect to this node from the web.",
"cmps.connect.ConnectPage.pageTitle": "Connect",
"cmps.connect.ConnectPage.description": "Create sessions to connect to your node from external apps and services.",
"cmps.connect.SessionRowHeader.label": "Label",
"cmps.connect.SessionRowHeader.type": "Type",
"cmps.connect.SessionRowHeader.state": "State",
@ -115,8 +115,8 @@
"cmps.home.HomePage.dashDesc": "Easily monitor routing activity and manage your channels through the dashboard.",
"cmps.home.YoutubeModal.title": "Get Connected",
"cmps.home.YoutubeModal.desc": "Get Connected with Lightning Node Connect",
"cmps.history.HistoryPage.backText": "Lightning Loop",
"cmps.history.HistoryPage.pageTitle": "History",
"cmps.history.HistoryPage.backText": "Liquidity",
"cmps.history.HistoryPage.pageTitle": "Activity",
"cmps.history.HistoryRowHeader.status": "Status",
"cmps.history.HistoryRowHeader.amount": "Amount",
"cmps.history.HistoryRowHeader.type": "Type",
@ -145,12 +145,12 @@
"cmps.loop.SelectedChannels.useAnyChannel": "Automatic channel selection",
"cmps.loop.LoopActions.loopMinimumNote": "Minimum Loop amount is {{min}}",
"cmps.loop.LoopActions.loopInNote": "Currently, multiple channel Loop In is supported only with a single peer.",
"cmps.loop.LoopActions.registerSidecar": "Register Sidecar Channel",
"cmps.loop.LoopPage.pageTitle": "Lightning Loop",
"cmps.loop.LoopHistory.emptyMsg": "After performing swaps, you will see ongoing loops and history here.",
"cmps.loop.LoopTiles.history": "Loop History",
"cmps.loop.LoopTiles.inbound": "Total Inbound Liquidity",
"cmps.loop.LoopTiles.outbound": "Total Outbound Liquidity",
"cmps.loop.LoopActions.registerSidecar": "Open Sidecar Channel",
"cmps.loop.LoopPage.pageTitle": "Liquidity",
"cmps.loop.LoopHistory.emptyMsg": "After performing swaps, you will see ongoing activity and history here.",
"cmps.loop.LoopTiles.history": "Recent Activity",
"cmps.loop.LoopTiles.inbound": "Inbound Liquidity",
"cmps.loop.LoopTiles.outbound": "Outbound Liquidity",
"cmps.loop.processing.FailedSwap.dismissTip": "Dismiss Failed Loop",
"cmps.loop.processing.ProcessingSwaps.title": "Processing Loops",
"cmps.loop.processing.ProcessingSwaps.closeTip": "Close",
@ -191,14 +191,14 @@
"cmps.layout.NavMenu.settings": "Settings",
"cmps.layout.NavMenu.liquidityHeader": "Liquidity",
"cmps.layout.NavMenu.loop": "Loop",
"cmps.layout.NavMenu.history": "Loop History",
"cmps.layout.NavMenu.history": "Activity",
"cmps.layout.NavMenu.pool": "Pool",
"cmps.layout.NavMenu.connectHeader": "Connect",
"cmps.layout.NavMenu.connect": "Lightning Node Connect",
"cmps.NodeStatus.title": "Node Status",
"cmps.NodeStatus.offchainTip": "Off-chain Funds",
"cmps.NodeStatus.onchainTip": "On-chain Funds",
"cmps.pool.PoolPage.pageTitle": "Lightning Pool",
"cmps.pool.PoolPage.pageTitle": "Channel Marketplace",
"cmps.pool.PoolPage.exportTip": "Download CSV of Leases",
"cmps.pool.account.AccountSummary.account": "Account",
"cmps.pool.account.AccountSummary.expiresIn": "Expires in",

View file

@ -1,13 +1,27 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0d1017;
color: #e5e7eb;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}

View file

@ -11,8 +11,11 @@ import {
AuthStore,
BatchStore,
ChannelStore,
NetworkGraphStore,
NodeConnectionStore,
NodeStore,
OrderStore,
PaymentActivityStore,
RouterStore,
SessionStore,
SettingsStore,
@ -45,12 +48,15 @@ export class Store {
authStore = new AuthStore(this);
batchStore = new BatchStore(this);
channelStore = new ChannelStore(this);
networkGraphStore = new NetworkGraphStore(this);
nodeConnectionStore = new NodeConnectionStore(this);
swapStore = new SwapStore(this);
nodeStore = new NodeStore(this);
orderStore = new OrderStore(this);
settingsStore = new SettingsStore(this);
sessionStore = new SessionStore(this);
subServerStore = new SubServerStore(this);
paymentActivityStore = new PaymentActivityStore(this);
/** the store which synchronizes with the browser history */
router: RouterStore;
@ -120,34 +126,44 @@ export class Store {
this.settingsStore.init();
this.swapStore.init();
this.batchStore.init();
this.nodeConnectionStore.init();
await this.authStore.init();
runInAction(() => {
this.initialized = true;
});
// this function will automatically run whenever the authenticated
// flag is changed
// Unauthenticated routes that should not trigger a redirect
const publicPaths = [
`${PUBLIC_URL}/`,
`${PUBLIC_URL}/connect-node`,
`${PUBLIC_URL}/get-node`,
`${PUBLIC_URL}/explore-network`,
`${PUBLIC_URL}/auth`,
];
autorun(
async () => {
if (this.authStore.authenticated) {
// go to the Loop page when the user is authenticated. it can be from
// entering a password or from loading the credentials from storage.
// only do this if the auth page is currently being viewed, otherwise
// stay on the current page (ex: history, settings)
if (document.location.pathname === `${PUBLIC_URL}/`) {
// Redirect to home if currently on a public/onboarding page
const current = document.location.pathname;
const onPublic = publicPaths.some(p => current === p);
if (onPublic) {
runInAction(() => {
this.appView.goToHome();
});
}
// also fetch all the data we need
this.fetchAllData();
// connect and subscribe to the server-side streams
this.fetchAllData().then(() => {
this.nodeConnectionStore.registerPrimaryNode();
});
this.connectToStreams();
this.subscribeToStreams();
} else {
// go to auth page if we are not authenticated
this.appView.gotoAuth();
// unsubscribe from streams since we are no longer authenticated
// Only redirect to welcome if on a protected (app) route
const current = document.location.pathname;
const onPublic = publicPaths.some(p => current === p);
if (!onPublic) {
this.appView.gotoAuth();
}
this.unsubscribeFromStreams();
}
},
@ -163,7 +179,9 @@ export class Store {
await this.nodeStore.fetchInfo();
await this.channelStore.fetchChannels();
await this.nodeStore.fetchBalances();
await this.sessionStore.fetchSessions();
if (this.subServerStore.subServers.lit.running) {
await this.sessionStore.fetchSessions();
}
if (this.swapStore.canFetchData) {
await this.swapStore.fetchSwaps();
@ -187,6 +205,7 @@ export class Store {
const { lnd, loop } = this.api;
lnd.on('transaction', this.nodeStore.onTransaction);
lnd.on('channel', this.channelStore.onChannelEvent);
lnd.on('invoice', this.paymentActivityStore.onInvoiceUpdate);
loop.on('monitor', this.swapStore.onSwapUpdate);
}
@ -197,6 +216,7 @@ export class Store {
const { lnd, loop } = this.api;
lnd.off('transaction', this.nodeStore.onTransaction);
lnd.off('channel', this.channelStore.onChannelEvent);
lnd.off('invoice', this.paymentActivityStore.onInvoiceUpdate);
loop.off('monitor', this.swapStore.onSwapUpdate);
}
}

View file

@ -1,7 +1,9 @@
import { makeAutoObservable, runInAction } from 'mobx';
import { Buffer } from 'buffer';
import LNC from '@lightninglabs/lnc-web';
import { prefixTranslation } from 'util/translate';
import { Store } from 'store';
import LncApi from 'api/lncApi';
const { l } = prefixTranslation('stores.authStore');
@ -26,6 +28,9 @@ export default class AuthStore {
/** the password encoded to base64 */
credentials = '';
/** whether the current session is via LNC */
isLnc = false;
errors = { mainErr: '', litDetail: '', lndDetail: '' };
constructor(store: Store) {
@ -131,16 +136,164 @@ export default class AuthStore {
}
async validate() {
// test the credentials by making an API call to getInfo
await this._store.api.lnd.getInfo();
// if no error is thrown above then the credentials are valid
this._store.log.info('authentication successful');
runInAction(() => {
// setting this to true will automatically show the Loop page
this.authenticated = true;
});
}
private _withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms / 1000}s`)),
ms,
);
promise.then(
v => {
clearTimeout(timer);
resolve(v);
},
e => {
clearTimeout(timer);
reject(e);
},
);
});
}
/**
* Connect via LNC pairing phrase (first time)
*/
async loginWithLnc(pairingPhrase: string, password: string) {
this._store.log.info('attempting LNC connection');
if (!pairingPhrase.trim()) throw new Error('Pairing phrase is required');
if (!password.trim()) throw new Error('Password is required');
const lnc = new LNC({
pairingPhrase: pairingPhrase.trim(),
password,
});
try {
await this._withTimeout(lnc.connect(), 30_000, 'Connection');
} catch (err: any) {
const msg = err?.message || '';
if (msg.includes('timed out')) {
try {
lnc.disconnect();
} catch {
/* best-effort cleanup */
}
throw new Error(
'Connection timed out. Make sure litd is running on your node and try a fresh pairing phrase.',
);
}
if (
msg.includes('WASM') ||
msg.includes('proxy') ||
msg.includes('stream not found')
) {
throw new Error(
'Could not reach your node. Make sure litd is running and the pairing phrase hasn\u2019t already been used. Generate a new one with: litcli sessions add --label="web" --type admin',
);
}
throw err;
}
if (!lnc.isConnected) {
throw new Error(
'Connection failed. Verify litd is running and reachable, then try a fresh pairing phrase.',
);
}
const lncApi = new LncApi(lnc);
runInAction(() => {
this._store.api.lnd = lncApi as any;
this.isLnc = true;
});
try {
await this._withTimeout(this.validate(), 15_000, 'Validation');
LncApi.markPaired();
} catch (error: any) {
lnc.disconnect();
runInAction(() => {
this.isLnc = false;
});
if (error?.message?.includes('timed out')) {
throw new Error(
'Connected to node but verification timed out. Your node may be slow to respond — try again.',
);
}
throw new Error(
'Connected but could not verify node access. Check your pairing phrase.',
);
}
}
/**
* Reconnect via LNC using saved credentials + password
*/
async reconnectLnc(password: string) {
this._store.log.info('attempting LNC reconnection with saved credentials');
if (!password.trim()) throw new Error('Password is required');
const lnc = new LNC({ password });
try {
await this._withTimeout(lnc.connect(), 12_000, 'Reconnection');
} catch (err: any) {
try {
lnc.disconnect();
} catch {
/* best-effort cleanup */
}
LncApi.clearPaired();
const msg = err?.message || '';
if (
msg.includes('timed out') ||
msg.includes('stream not found') ||
msg.includes('WASM') ||
msg.includes('proxy') ||
msg.includes('closed network')
) {
throw new Error(
'Session expired. Please connect with a new pairing phrase.',
);
}
throw err;
}
if (!lnc.isConnected) {
LncApi.clearPaired();
throw new Error(
'Session expired. Please connect with a new pairing phrase.',
);
}
const lncApi = new LncApi(lnc);
runInAction(() => {
this._store.api.lnd = lncApi as any;
this.isLnc = true;
});
await this._withTimeout(this.validate(), 15_000, 'Validation');
}
/** Disconnect LNC and clear pairing data */
disconnectLnc() {
if (this.isLnc) {
const api = this._store.api.lnd as any;
if (api?.lnc?.disconnect) {
api.lnc.disconnect();
}
}
LncApi.clearPaired();
runInAction(() => {
this.isLnc = false;
this.authenticated = false;
});
}
/**
* load and validate credentials from the browser's session storage
*/
@ -151,10 +304,8 @@ export default class AuthStore {
this.setCredentials(creds);
this._store.log.info('found credentials. validating');
try {
// test the credentials by making an API call to getInfo
await this.validate();
} catch (error) {
// clear the credentials and swallow the error
this.setCredentials('');
this._store.log.error('cleared invalid credentials in sessionStorage');
}

View file

@ -4,8 +4,11 @@ export { default as BatchStore } from './batchStore';
export { default as AuthStore } from './authStore';
export { default as ChannelStore } from './channelStore';
export { default as NodeStore } from './nodeStore';
export { default as NetworkGraphStore } from './networkGraphStore';
export { default as NodeConnectionStore } from './nodeConnectionStore';
export { default as SettingsStore } from './settingsStore';
export { default as SwapStore } from './swapStore';
export { default as RouterStore } from './routerStore';
export { default as SessionStore } from './sessionStore';
export { default as SubServerStore } from './subServerStore';
export { default as PaymentActivityStore } from './paymentActivityStore';

View file

@ -0,0 +1,124 @@
import { makeAutoObservable, runInAction } from 'mobx';
import * as LND from 'types/generated/lnd_pb';
import { Store } from '../store';
export interface MempoolNode {
publicKey: string;
alias: string;
channels: number;
capacity: number;
firstSeen: number;
updatedAt: number;
city?: { en?: string } | null;
country?: { en?: string } | null;
iso_code?: string | null;
color?: string;
}
const MEMPOOL = 'https://mempool.space/api/v1/lightning';
export default class NetworkGraphStore {
private _store: Store;
nodes: LND.LightningNode.AsObject[] = [];
edges: LND.ChannelEdge.AsObject[] = [];
/** Raw mempool data keyed by pubkey for capacity/channel counts */
mempoolData: Map<string, MempoolNode> = new Map();
networkInfo: LND.NetworkInfo.AsObject | null = null;
loading = false;
error = '';
constructor(store: Store) {
makeAutoObservable(this, {}, { deep: false, autoBind: true });
this._store = store;
}
get nodesByPubkey(): Map<string, LND.LightningNode.AsObject> {
const map = new Map<string, LND.LightningNode.AsObject>();
for (const node of this.nodes) {
map.set(node.pubKey, node);
}
return map;
}
get selfPubkey(): string {
return this._store.nodeStore.pubkey;
}
/**
* Fetch real Lightning Network data from mempool.space.
* Gets top ~100 nodes by connectivity/capacity and their
* interconnecting channels actual real-world network data.
*/
async fetchGraph() {
if (this.loading) return;
this.loading = true;
this.error = '';
try {
const [connRes, liqRes, statsRes] = await Promise.all([
fetch(`${MEMPOOL}/nodes/rankings/connectivity`),
fetch(`${MEMPOOL}/nodes/rankings/liquidity`),
fetch(`${MEMPOOL}/statistics/latest`),
]);
const connData: MempoolNode[] = await connRes.json();
const liqData: MempoolNode[] = await liqRes.json();
const statsData = await statsRes.json();
// Merge top nodes from both rankings, deduplicating
const nodeMap = new Map<string, MempoolNode>();
const topConn = connData.slice(0, 60);
const topLiq = liqData.slice(0, 60);
for (const n of [...topConn, ...topLiq]) {
if (!nodeMap.has(n.publicKey)) nodeMap.set(n.publicKey, n);
}
const allNodes = Array.from(nodeMap.values());
const nodeList: LND.LightningNode.AsObject[] = allNodes.map(n => ({
lastUpdate: n.updatedAt || 0,
pubKey: n.publicKey,
alias: n.alias || '',
addressesList: [],
color: n.color || '#8b5cf6',
featuresMap: [],
customRecordsMap: [],
}));
const totalCap = allNodes.reduce((s, n) => s + (n.capacity || 0), 0);
runInAction(() => {
this.nodes = nodeList;
this.edges = [];
this.mempoolData = nodeMap;
this.networkInfo = {
graphDiameter: 0,
avgOutDegree: 0,
maxOutDegree: 0,
numNodes: statsData.latest?.node_count || nodeList.length,
numChannels: statsData.latest?.channel_count || 0,
totalNetworkCapacity: (statsData.latest?.total_capacity || totalCap).toString(),
avgChannelSize: 0,
minChannelSize: '0',
maxChannelSize: '0',
medianChannelSizeSat: '0',
numZombieChans: '0',
};
this.loading = false;
});
} catch (err: any) {
runInAction(() => {
this.error = err?.message || 'Failed to fetch network data';
this.loading = false;
});
}
}
getNodeAlias(pubkey: string): string {
const found = this.nodesByPubkey.get(pubkey);
return found?.alias || pubkey.substring(0, 12) + '...';
}
getNodeColor(pubkey: string): string {
return this.nodesByPubkey.get(pubkey)?.color || '#8b5cf6';
}
}

View file

@ -0,0 +1,347 @@
import { makeAutoObservable, observable, runInAction } from 'mobx';
import LNC from '@lightninglabs/lnc-web';
import LncApi from 'api/lncApi';
import { Store } from 'store';
const STORAGE_KEY = 'lit-node-connections';
export interface NodeChannel {
chanId: string;
remotePubkey: string;
capacity: number;
localBalance: number;
remoteBalance: number;
active: boolean;
alias: string;
}
interface SavedNodeMeta {
id: string;
label: string;
type: 'lnc' | 'direct';
lncNamespace?: string;
}
export class NodeConnection {
id: string;
label: string;
type: 'lnc' | 'direct';
connected = false;
connecting = false;
error = '';
api: LncApi | null = null;
pubkey = '';
alias = '';
channelBalanceLocal = 0;
channelBalanceRemote = 0;
walletBalanceConfirmed = 0;
walletBalanceUnconfirmed = 0;
channels: NodeChannel[] = [];
lncNamespace: string;
private _lnc: LNC | null = null;
constructor(id: string, label: string, type: 'lnc' | 'direct', namespace?: string) {
this.id = id;
this.label = label;
this.type = type;
this.lncNamespace = namespace || `lnc-node-${id}`;
makeAutoObservable(this, {}, { deep: false, autoBind: true });
}
async connectLnc(pairingPhrase: string, password: string) {
this.connecting = true;
this.error = '';
try {
const lnc = new LNC({
pairingPhrase: pairingPhrase.trim(),
password,
namespace: this.lncNamespace,
});
await lnc.connect();
if (!lnc.isConnected) {
throw new Error('Failed to establish LNC connection');
}
this._lnc = lnc;
const api = new LncApi(lnc);
runInAction(() => {
this.api = api;
this.connected = true;
this.connecting = false;
});
LncApi.markPairedFor(this.lncNamespace);
await this.fetchInfo();
await this.fetchChannels();
} catch (err: any) {
runInAction(() => {
this.connecting = false;
this.error = err?.message || 'Connection failed';
this.connected = false;
});
throw err;
}
}
async reconnectLnc(password: string) {
this.connecting = true;
this.error = '';
try {
const lnc = new LNC({
password,
namespace: this.lncNamespace,
});
await lnc.connect();
if (!lnc.isConnected) {
throw new Error('Failed to reconnect via LNC');
}
this._lnc = lnc;
const api = new LncApi(lnc);
runInAction(() => {
this.api = api;
this.connected = true;
this.connecting = false;
});
await this.fetchInfo();
await this.fetchChannels();
} catch (err: any) {
runInAction(() => {
this.connecting = false;
this.error = err?.message || 'Reconnection failed';
this.connected = false;
});
throw err;
}
}
async fetchInfo() {
if (!this.api) return;
try {
const info = await this.api.getInfo();
runInAction(() => {
this.pubkey = info.identityPubkey || '';
this.alias = info.alias || this.label;
});
} catch {
// non-fatal
}
try {
const cb = await this.api.channelBalance();
const wb = await this.api.walletBalance();
runInAction(() => {
this.channelBalanceLocal = Number(cb.localBalance?.sat || cb.balance || 0);
this.channelBalanceRemote = Number(cb.remoteBalance?.sat || 0);
this.walletBalanceConfirmed = Number(wb.confirmedBalance || wb.totalBalance || 0);
this.walletBalanceUnconfirmed = Number(wb.unconfirmedBalance || 0);
});
} catch {
// non-fatal
}
}
async fetchChannels() {
if (!this.api) return;
try {
const res: any = await this.api.listChannels();
const raw: any[] = res.channelsList || res.channels || [];
const chans: NodeChannel[] = raw.map((ch: any) => ({
chanId: ch.chanId || '',
remotePubkey: ch.remotePubkey || '',
capacity: Number(ch.capacity || 0),
localBalance: Number(ch.localBalance || 0),
remoteBalance: Number(ch.remoteBalance || 0),
active: !!ch.active,
alias: '',
}));
runInAction(() => {
this.channels = chans;
});
} catch {
// non-fatal
}
}
disconnect() {
if (this._lnc) {
try {
this._lnc.disconnect();
} catch {
// ignore
}
this._lnc = null;
}
this.api = null;
this.connected = false;
this.channels = [];
this.pubkey = '';
this.alias = '';
}
get initial() {
return (this.alias || this.label || '?').charAt(0).toUpperCase();
}
get displayName() {
return this.alias || this.label || `Node ${this.id}`;
}
}
export default class NodeConnectionStore {
private _store: Store;
connections = observable.map<string, NodeConnection>();
activeLeftId: string | null = null;
activeRightId: string | null = null;
constructor(store: Store) {
makeAutoObservable(this, {}, { deep: false, autoBind: true });
this._store = store;
}
get connectedNodes(): NodeConnection[] {
return Array.from(this.connections.values()).filter(n => n.connected);
}
get allNodes(): NodeConnection[] {
return Array.from(this.connections.values());
}
get activeLeft(): NodeConnection | null {
if (!this.activeLeftId) return null;
return this.connections.get(this.activeLeftId) || null;
}
get activeRight(): NodeConnection | null {
if (!this.activeRightId) return null;
return this.connections.get(this.activeRightId) || null;
}
get hasMultipleConnected(): boolean {
return this.connectedNodes.length >= 2;
}
get channelBetweenActive(): NodeChannel | null {
const left = this.activeLeft;
const right = this.activeRight;
if (!left?.connected || !right?.connected) return null;
return (
left.channels.find(ch => ch.remotePubkey === right.pubkey) ||
right.channels.find(ch => ch.remotePubkey === left.pubkey) ||
null
);
}
setActiveLeft(id: string) {
this.activeLeftId = id;
}
setActiveRight(id: string) {
this.activeRightId = id;
}
async addLncNode(
label: string,
pairingPhrase: string,
password: string,
): Promise<NodeConnection> {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const node = new NodeConnection(id, label, 'lnc');
this.connections.set(id, node);
await node.connectLnc(pairingPhrase, password);
this._autoAssignActive(id);
this._persist();
return node;
}
async reconnectNode(id: string, password: string) {
const node = this.connections.get(id);
if (!node || node.type !== 'lnc') return;
await node.reconnectLnc(password);
this._autoAssignActive(id);
}
removeNode(id: string) {
const node = this.connections.get(id);
if (node) {
node.disconnect();
LncApi.clearPairedFor(node.lncNamespace);
}
this.connections.delete(id);
if (this.activeLeftId === id) {
this.activeLeftId = this.connectedNodes[0]?.id || null;
}
if (this.activeRightId === id) {
this.activeRightId = this.connectedNodes[1]?.id || null;
}
this._persist();
}
registerPrimaryNode() {
const { nodeStore } = this._store;
const pk = nodeStore.pubkey;
if (!pk) return;
const existing = Array.from(this.connections.values()).find(n => n.pubkey === pk);
if (existing) return;
const node = new NodeConnection('primary', nodeStore.alias || 'My Node', 'direct');
node.connected = true;
node.pubkey = pk;
node.alias = nodeStore.alias || 'My Node';
node.api = this._store.api.lnd as any;
this.connections.set('primary', node);
if (!this.activeLeftId) {
this.activeLeftId = 'primary';
}
}
async refreshAllData() {
const promises = this.connectedNodes.map(async n => {
await n.fetchInfo();
await n.fetchChannels();
});
await Promise.allSettled(promises);
}
init() {
this._restore();
}
private _autoAssignActive(id: string) {
if (!this.activeLeftId) {
this.activeLeftId = id;
} else if (!this.activeRightId && this.activeLeftId !== id) {
this.activeRightId = id;
}
}
private _persist() {
const metas: SavedNodeMeta[] = Array.from(this.connections.values())
.filter(n => n.id !== 'primary')
.map(n => ({
id: n.id,
label: n.label,
type: n.type,
lncNamespace: n.lncNamespace,
}));
localStorage.setItem(STORAGE_KEY, JSON.stringify(metas));
}
private _restore() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const metas: SavedNodeMeta[] = JSON.parse(raw);
for (const meta of metas) {
const node = new NodeConnection(
meta.id,
meta.label,
meta.type,
meta.lncNamespace,
);
this.connections.set(meta.id, node);
}
} catch {
// ignore corrupt data
}
}
}

View file

@ -0,0 +1,83 @@
import { makeAutoObservable, observable } from 'mobx';
import { USE_SAMPLE_DATA } from 'config';
import * as LND from 'types/generated/lnd_pb';
import { Store } from 'store/store';
export interface PaymentEvent {
/** 'send' for outgoing, 'receive' for incoming */
direction: 'send' | 'receive';
/** amount in sats */
amountSat: number;
/** unix timestamp ms */
timestamp: number;
}
export default class PaymentActivityStore {
private _store: Store;
private _simulationTimer?: ReturnType<typeof setTimeout>;
/** queue of recent payment events for bolt animations */
pendingEvents = observable.array<PaymentEvent>([], {
deep: false,
});
constructor(store: Store) {
makeAutoObservable(this, {}, { deep: false, autoBind: true });
this._store = store;
}
/** called when a SubscribeInvoices update arrives */
onInvoiceUpdate(invoice: LND.Invoice.AsObject) {
if (invoice.state === 1) {
const amt = parseInt(invoice.amtPaidSat, 10) || parseInt(invoice.value, 10) || 0;
this.pendingEvents.push({
direction: 'receive',
amountSat: amt,
timestamp: Date.now(),
});
}
}
/** called after a successful outgoing payment */
onPaymentSent(amountSat: number) {
this.pendingEvents.push({
direction: 'send',
amountSat,
timestamp: Date.now(),
});
}
/** consume the next pending event (returns undefined if empty) */
dequeue(): PaymentEvent | undefined {
if (this.pendingEvents.length === 0) return undefined;
return this.pendingEvents.shift();
}
get hasPending(): boolean {
return this.pendingEvents.length > 0;
}
/** generate fake payment events in sample-data dev mode */
startSimulation() {
if (!USE_SAMPLE_DATA) return;
this.stopSimulation();
const tick = () => {
const dir = Math.random() > 0.5 ? 'send' : 'receive';
const amt = Math.floor(Math.random() * 500000) + 1000;
this.pendingEvents.push({
direction: dir as 'send' | 'receive',
amountSat: amt,
timestamp: Date.now(),
});
this._simulationTimer = setTimeout(tick, 2000 + Math.random() * 6000);
};
this._simulationTimer = setTimeout(tick, 1000 + Math.random() * 3000);
}
stopSimulation() {
if (this._simulationTimer) {
clearTimeout(this._simulationTimer);
this._simulationTimer = undefined;
}
}
}

View file

@ -86,7 +86,12 @@ export default class SessionStore {
this._store.log.info('updated sessionStore.sessions', toJS(this.sessions));
});
} catch (error: any) {
this._store.appView.handleError(error, 'Unable to fetch sessions');
const msg = error?.message || String(error);
if (msg.includes('not ready')) {
this._store.log.info('LiT sessions sub-server not ready, skipping');
} else {
this._store.appView.handleError(error, 'Unable to fetch sessions');
}
}
}

View file

@ -6,6 +6,7 @@ import { SubServerStatus } from 'types/state';
interface SubServers {
loop: SubServerStatus;
pool: SubServerStatus;
lit: SubServerStatus;
}
export default class SubServerStore {
@ -24,6 +25,11 @@ export default class SubServerStore {
running: true,
error: '',
},
lit: {
disabled: false,
running: true,
error: '',
},
};
constructor(store: Store) {
@ -44,6 +50,8 @@ export default class SubServerStore {
this.subServers.pool = serverStatus;
} else if (serverName === 'loop') {
this.subServers.loop = serverStatus;
} else if (serverName === 'lit') {
this.subServers.lit = serverStatus;
}
});
});

View file

@ -19,6 +19,8 @@ export default class AppView {
/** indicates if the tour is visible */
tourVisible = false;
tourActiveStep = 0;
/** which graph view is active: 'mynode' or 'network' */
graphViewMode: 'mynode' | 'network' = 'mynode';
/** a collection of alerts to display as toasts */
alerts = observable.map<number, Alert>();
@ -40,10 +42,26 @@ export default class AppView {
}
}
/** Change to the Auth page */
/** Change to the Welcome/Auth page */
gotoAuth() {
this.goTo(`/`);
this._store.log.info('Go to the Auth page');
this._store.log.info('Go to the Welcome page');
}
/** Change to the Home page (My Node view) */
goToMyNode() {
this.graphViewMode = 'mynode';
this.goTo(`/home`);
this._store.settingsStore.autoCollapseSidebar();
this._store.log.info('Go to My Node');
}
/** Change to the Home page (Network view) */
goToNetwork() {
this.graphViewMode = 'network';
this.goTo(`/home`);
this._store.settingsStore.autoCollapseSidebar();
this._store.log.info('Go to Network');
}
/** Change to the Home page */
@ -57,10 +75,6 @@ export default class AppView {
goToLoop() {
this.goTo(`/loop`);
this._store.settingsStore.autoCollapseSidebar();
if (!this._store.settingsStore.tourAutoShown) {
this.showTour();
this._store.settingsStore.tourAutoShown = true;
}
this._store.log.info('Go to the Loop page');
}

View file

@ -1159,11 +1159,266 @@ export const litSubServerStatus: STATUS.SubServerStatusResp.AsObject = {
],
};
// Well-known Lightning Network nodes for realistic sample graph data
const networkNodes: Array<{
pubKey: string;
alias: string;
color: string;
addr: string;
}> = [
{
pubKey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f',
alias: 'ACINQ',
color: '#49daaa',
addr: '34.239.230.56:9735',
},
{
pubKey: '035e4ff418fc8b5554c5d9eea66396c227bd3a1a07c54c2b1174b61089ecc0e952',
alias: 'River Financial',
color: '#ff6600',
addr: '104.196.249.140:9735',
},
{
pubKey: '0217890e3aad8d35bc054f43acc00084b25571e1b9d4cc',
alias: 'Bitfinex',
color: '#68b646',
addr: '52.50.244.44:9735',
},
{
pubKey: '021c97a90a411ff2b10dc2a8e32de2f29d2fa49d41bfbb5',
alias: 'Kraken',
color: '#5741d9',
addr: '52.13.118.208:9735',
},
{
pubKey: '02f1a8c87607f415c8f22c00cf8407e42ba8c0b0e78f8a',
alias: 'WalletOfSatoshi',
color: '#f5a623',
addr: '170.75.163.209:9735',
},
{
pubKey: '030c3f19d742ca294a55c00376b3b355c3c90d61c6b6fe',
alias: 'OpenNode',
color: '#3b99fc',
addr: '18.191.253.246:9735',
},
{
pubKey: '0331f80652fb840239df8dc99205792bba2e559a05469a',
alias: 'Fold',
color: '#e8912d',
addr: '35.238.153.25:9735',
},
{
pubKey: '026165850492521f4ac8abd9bd8088123446d126f648be',
alias: 'Blockstream Store',
color: '#00c3ff',
addr: '104.197.12.123:9735',
},
{
pubKey: '02ad6fb8d693dc1e4569bcedefadf5f72a931ae027dc0',
alias: 'CoinGate',
color: '#f7931a',
addr: '3.124.63.44:9735',
},
{
pubKey: '024bfaf0cabe7f874fd33ebf7c6f4e5385971fc504ef3',
alias: 'Muun',
color: '#1a74e2',
addr: '18.229.160.104:9735',
},
{
pubKey: '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff40',
alias: 'Voltage',
color: '#7b1af7',
addr: '52.7.134.156:9735',
},
{
pubKey: '02d96eadea3d780104449aca5c93461ce67c1564e2e11',
alias: 'Bitstamp',
color: '#15c46c',
addr: '99.79.25.200:9735',
},
{
pubKey: '0390b5d4492dc2f5318e5233ab2cebf6d48914881a33',
alias: 'LNBig',
color: '#ff9900',
addr: '172.81.178.189:9735',
},
{
pubKey: '03cde60a6323f7122d5178255766e38114b4722ede08f',
alias: 'Zebedee',
color: '#00d632',
addr: '3.33.236.230:9735',
},
{
pubKey: '0242a4ae0c5bef18048fbecf995094b74bfb0f7391eb',
alias: 'CashApp',
color: '#00d54b',
addr: '35.245.60.138:9735',
},
{
pubKey: '029ef8a77b0ac5ec5991775e2c5c3e4d23ad827717e1',
alias: 'OKX',
color: '#171717',
addr: '54.214.123.251:9735',
},
];
const selfPubkey = lndGetInfo.identityPubkey;
const makeNode = (n: typeof networkNodes[number]): LND.LightningNode.AsObject => ({
lastUpdate: Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 86400),
pubKey: n.pubKey,
alias: n.alias,
addressesList: [{ addr: n.addr, network: 'tcp' }],
color: n.color,
featuresMap: [
[0, { name: 'data-loss-protect', isRequired: true, isKnown: true }],
[9, { name: 'tlv-onion', isRequired: false, isKnown: true }],
],
customRecordsMap: [],
});
const makePolicyPair = (cap: number) => ({
node1Policy: {
timeLockDelta: 40,
minHtlc: '1000',
feeBaseMsat: '1000',
feeRateMilliMsat: `${Math.floor(Math.random() * 500) + 1}`,
disabled: false,
maxHtlcMsat: `${cap * 1000}`,
lastUpdate: Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 86400),
customRecordsMap: [] as Array<[number, Uint8Array | string]>,
inboundFeeBaseMsat: 0,
inboundFeeRateMilliMsat: 0,
},
node2Policy: {
timeLockDelta: 40,
minHtlc: '1000',
feeBaseMsat: '1000',
feeRateMilliMsat: `${Math.floor(Math.random() * 500) + 1}`,
disabled: false,
maxHtlcMsat: `${cap * 1000}`,
lastUpdate: Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 86400),
customRecordsMap: [] as Array<[number, Uint8Array | string]>,
inboundFeeBaseMsat: 0,
inboundFeeRateMilliMsat: 0,
},
});
const sampleGraphNodes: LND.LightningNode.AsObject[] = [
{
lastUpdate: Math.floor(Date.now() / 1000),
pubKey: selfPubkey,
alias: lndGetInfo.alias,
addressesList: [{ addr: '127.0.0.1:9735', network: 'tcp' }],
color: '#cccccc',
featuresMap: lndGetInfo.featuresMap,
customRecordsMap: [],
},
...networkNodes.map(makeNode),
];
let edgeIdCounter = 800000000000000;
const sampleGraphEdges: LND.ChannelEdge.AsObject[] = [];
// Channels from self to first 6 network nodes (matching local channels)
for (let i = 0; i < Math.min(6, networkNodes.length); i++) {
const cap = 5000000 + Math.floor(Math.random() * 45000000);
sampleGraphEdges.push({
channelId: `${edgeIdCounter++}`,
chanPoint: `${txId}:${i + 100}`,
lastUpdate: Math.floor(Date.now() / 1000),
node1Pub: selfPubkey,
node2Pub: networkNodes[i].pubKey,
capacity: `${cap}`,
...makePolicyPair(cap),
customRecordsMap: [],
});
}
// Interconnections between network nodes for realistic topology
const interconnections: [number, number][] = [
[0, 1],
[0, 2],
[0, 3],
[0, 5],
[0, 8],
[0, 12],
[1, 2],
[1, 4],
[1, 6],
[1, 10],
[2, 3],
[2, 7],
[2, 11],
[3, 4],
[3, 9],
[3, 14],
[4, 5],
[4, 7],
[4, 13],
[5, 6],
[5, 9],
[5, 15],
[6, 8],
[6, 10],
[7, 9],
[7, 11],
[8, 10],
[8, 12],
[9, 11],
[9, 14],
[10, 13],
[10, 15],
[11, 12],
[12, 14],
[13, 15],
[14, 15],
];
for (const [a, b] of interconnections) {
const cap = 10000000 + Math.floor(Math.random() * 90000000);
sampleGraphEdges.push({
channelId: `${edgeIdCounter++}`,
chanPoint: `abcdef${a}${b}:0`,
lastUpdate: Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 86400 * 7),
node1Pub: networkNodes[a].pubKey,
node2Pub: networkNodes[b].pubKey,
capacity: `${cap}`,
...makePolicyPair(cap),
customRecordsMap: [],
});
}
export const lndDescribeGraph: LND.ChannelGraph.AsObject = {
nodesList: sampleGraphNodes,
edgesList: sampleGraphEdges,
};
export const lndGetNetworkInfo: LND.NetworkInfo.AsObject = {
graphDiameter: 6,
avgOutDegree: 4.5,
maxOutDegree: 12,
numNodes: sampleGraphNodes.length,
numChannels: sampleGraphEdges.length,
totalNetworkCapacity: `${sampleGraphEdges.reduce(
(s, e) => s + parseInt(e.capacity, 10),
0,
)}`,
avgChannelSize: 0,
minChannelSize: '5000000',
maxChannelSize: '100000000',
medianChannelSizeSat: '25000000',
numZombieChans: '0',
};
// collection of sample API responses
export const sampleApiResponses: Record<string, any> = {
'lnrpc.Lightning.GetInfo': lndGetInfo,
'lnrpc.Lightning.GetNodeInfo': lndGetNodeInfo,
'lnrpc.Lightning.GetChanInfo': lndGetChanInfo,
'lnrpc.Lightning.DescribeGraph': lndDescribeGraph,
'lnrpc.Lightning.GetNetworkInfo': lndGetNetworkInfo,
'lnrpc.Lightning.ChannelBalance': lndChannelBalance,
'lnrpc.Lightning.WalletBalance': lndWalletBalance,
'lnrpc.Lightning.ListChannels': lndListChannels,

8
app/vercel.json Normal file
View file

@ -0,0 +1,8 @@
{
"buildCommand": "CI=false yarn build",
"outputDirectory": "build",
"framework": "create-react-app",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}

View file

@ -2799,6 +2799,19 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@lightninglabs/lnc-core@0.3.5-alpha":
version "0.3.5-alpha"
resolved "https://registry.yarnpkg.com/@lightninglabs/lnc-core/-/lnc-core-0.3.5-alpha.tgz#7874d21eeb1bd01254d96fe5e37da7f06915e810"
integrity sha512-20rOA3ZiPxYRCppqLBxXEgaLCoSX4B5L1GTUrvuCaZAU29Ua0Yc1sJrd/A67vNxLOxxWFPkK0jKcSem3ZaGQLQ==
"@lightninglabs/lnc-web@^0.3.5-alpha":
version "0.3.5-alpha"
resolved "https://registry.yarnpkg.com/@lightninglabs/lnc-web/-/lnc-web-0.3.5-alpha.tgz#8ee9d66480811cb89d0d2d0c174bac3642069e6a"
integrity sha512-e0bO0GIYrIST8V+EbmLozReRC7loZ2izYGeqIyPenbbgNutmFZAQkmKflDaLoWy8S0Tw60QxX8WLnIEaRfBQoA==
dependencies:
"@lightninglabs/lnc-core" "0.3.5-alpha"
crypto-js "4.2.0"
"@mdx-js/react@^2.1.5":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-2.3.0.tgz#4208bd6d70f0d0831def28ef28c26149b03180b3"
@ -6642,6 +6655,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
crypto-js@4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
crypto-random-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
@ -10675,6 +10693,11 @@ lru-cache@^6.0.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.3.tgz#b40014d7d2d16d94130b87297a04a1f24874ae7c"
integrity sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==
lucide-react@^0.575.0:
version "0.575.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.575.0.tgz#90feaa4c140e9693e4ee9426d9927a6b833267ac"
integrity sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
@ -12413,10 +12436,10 @@ q@^1.1.2:
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
qrcode.react@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8"
integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==
qrcode.react@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-4.2.0.tgz#1bce8363f348197d145c0da640929a24c83cbca3"
integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
qs@6.13.0:
version "6.13.0"
@ -14345,6 +14368,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
three@0.160.0:
version "0.160.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.160.0.tgz#cd1e4dbd01aee0719280a9086d75545db52b7a8f"
integrity sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==
throat@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375"

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
docs/screenshots/graph.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB