Merge 65747216b7 into 23433a05ed
1
app/.gitignore
vendored
|
|
@ -21,3 +21,4 @@
|
|||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
app/public/icons/Lit-dark.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/public/icons/Lit.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
app/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/public/icons/bitcoin-logo.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
app/public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1,017 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
app/public/icons/nodl.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
app/public/icons/start9.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/public/icons/umbrel.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/public/icons/voltage.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
41
app/src/__mocks__/@lightninglabs/lnc-web.ts
Normal 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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
885
app/src/components/auth/ConnectNodePage.tsx
Normal 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 → <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't have a node? Get one
|
||||
<ChevronRight size={13} style={{ marginLeft: 2 }} />
|
||||
</S.FooterLink>
|
||||
</S.Content>
|
||||
</S.Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ConnectNodePage);
|
||||
861
app/src/components/auth/ExploreNetworkPage.tsx
Normal 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);
|
||||
433
app/src/components/auth/GetNodePage.tsx
Normal 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);
|
||||
546
app/src/components/auth/WelcomePage.tsx
Normal 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);
|
||||
|
|
@ -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;
|
||||
`}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
|||
655
app/src/components/common/AddNodeModal.tsx
Normal 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'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('');
|
||||
}}
|
||||
>
|
||||
← 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 "Lit App"
|
||||
</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'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('');
|
||||
}}
|
||||
>
|
||||
← 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'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);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
316
app/src/components/common/OpenChannelModal.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
755
app/src/components/common/TransactionModal.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
`}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
712
app/src/components/explore/ExplorePage.tsx
Normal 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);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
1634
app/src/components/home/GraphVisualization.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
414
app/src/components/home/NodePanel.tsx
Normal 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);
|
||||
`,
|
||||
};
|
||||
339
app/src/components/home/SplitNodeView.tsx
Normal 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;
|
||||
`,
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
505
app/src/components/layout/NodePicker.tsx
Normal 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);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
`}
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import LoopHistory from './LoopHistory';
|
|||
|
||||
const Styled = {
|
||||
TileSection: styled.section`
|
||||
margin-top: 90px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
294
app/src/components/tour/OnboardingModal.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
124
app/src/store/stores/networkGraphStore.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
347
app/src/store/stores/nodeConnectionStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
83
app/src/store/stores/paymentActivityStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"buildCommand": "CI=false yarn build",
|
||||
"outputDirectory": "build",
|
||||
"framework": "create-react-app",
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
BIN
docs/screenshots/activity.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/screenshots/explore.png
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
docs/screenshots/graph.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/liquidity.png
Normal file
|
After Width: | Height: | Size: 288 KiB |