mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-01-10 01:34:26 +01:00
Merge pull request #19 from MutinyWallet/send-basics
Get close to POC feature parity
This commit is contained in:
4
.env
4
.env
@@ -8,4 +8,6 @@
|
||||
|
||||
VITE_NETWORK="signet"
|
||||
VITE_PROXY="wss://p.mutinywallet.com"
|
||||
VITE_ESPLORA="https://mutinynet.com/api"
|
||||
VITE_ESPLORA="https://mutinynet.com/api"
|
||||
VITE_LSP="https://signet-lsp.mutinywallet.com"
|
||||
VITE_RGS="https://rgs.mutinynet.com"
|
||||
|
||||
13
package.json
13
package.json
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "mws",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "solid-start dev",
|
||||
"build": "solid-start build",
|
||||
@@ -9,8 +10,8 @@
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.15.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
||||
"@typescript-eslint/parser": "^5.58.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||
"@typescript-eslint/parser": "^5.59.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"esbuild": "^0.14.54",
|
||||
"eslint": "^8.38.0",
|
||||
@@ -18,7 +19,7 @@
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-solid": "0.11.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss": "^8.4.22",
|
||||
"solid-start-node": "^0.2.26",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "^4.9.5",
|
||||
@@ -29,13 +30,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.8.2",
|
||||
"@kobalte/tailwindcss": "^0.5.0",
|
||||
"@modular-forms/solid": "^0.12.0",
|
||||
"@motionone/solid": "^10.16.0",
|
||||
"@mutinywallet/node-manager": "^0.2.4",
|
||||
"@mutinywallet/mutiny-wasm": "^0.2.5",
|
||||
"@nostr-dev-kit/ndk": "^0.0.13",
|
||||
"@solidjs/meta": "^0.28.4",
|
||||
"@solidjs/router": "^0.8.2",
|
||||
"class-variance-authority": "^0.4.0",
|
||||
"nostr-tools": "^1.8.4",
|
||||
"nostr-tools": "^1.9.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"solid-js": "^1.7.3",
|
||||
"solid-qr-code": "^0.0.8",
|
||||
|
||||
690
pnpm-lock.yaml
generated
690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
src/assets/check-spinner.gif
Normal file
BIN
src/assets/check-spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 526 KiB |
3
src/assets/icons/airplane.svg
Normal file
3
src/assets/icons/airplane.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.597 3.477a1.5 1.5 0 0 1 .278 1.622l-11.768 27a1.5 1.5 0 0 1-2.387.509L14.157 25.7l-3.036 4.578a1.5 1.5 0 0 1-2.73-.58L6.825 20.34.872 17.596a1.5 1.5 0 0 1 .125-2.775l33-11.734a1.5 1.5 0 0 1 1.6.39ZM9.827 20.1l.896 5.35 1.902-2.869a1.5 1.5 0 0 1 .236-.276l11.04-10.122L9.826 20.1Zm8.665-8.317-13.02 4.63 2.633 1.213 10.387-5.843Zm11.805-1.395-14.2 13.02 6.098 5.57 8.102-18.59Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
3
src/assets/icons/close.svg
Normal file
3
src/assets/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m14 14 20 20m-20 0 20-20" stroke="#FFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
5
src/assets/icons/paste.svg
Normal file
5
src/assets/icons/paste.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="#000"/>
|
||||
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="#000"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
3
src/assets/icons/receive.svg
Normal file
3
src/assets/icons/receive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.333 13.008a.667.667 0 0 1-.666.667h-6A.667.667 0 0 1 3 13.008v-6a.667.667 0 1 1 1.333 0v4.39l7.348-7.346a.667.667 0 1 1 .942.942l-7.347 7.348h4.39c.369 0 .667.298.667.666Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
BIN
src/assets/party.gif
Normal file
BIN
src/assets/party.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
8
src/assets/svg/Paste.tsx
Normal file
8
src/assets/svg/Paste.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function Paste() {
|
||||
return (<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="currentColor" />
|
||||
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="currentColor" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
6
src/assets/svg/Scan.tsx
Normal file
6
src/assets/svg/Scan.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export function Scan() {
|
||||
return (<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 3H30.5C32.1569 3 33.5 4.34315 33.5 6V10.5H36.5V6C36.5 2.68629 33.8137 0 30.5 0H26V3ZM11 3V0H6.5C3.18629 0 0.5 2.68629 0.5 6V10.5H3.5V6C3.5 4.34315 4.84315 3 6.5 3H11ZM3.5 25.5H0.5V30C0.5 33.3137 3.18629 36 6.5 36H11V33H6.5C4.84315 33 3.5 31.6569 3.5 30V25.5ZM26 33V36H30.5C33.8137 36 36.5 33.3137 36.5 30V25.5H33.5V30C33.5 31.6569 32.1569 33 30.5 33H26Z" fill="currentColor" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
203
src/components/Activity.tsx
Normal file
203
src/components/Activity.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import send from '~/assets/icons/send.svg';
|
||||
import receive from '~/assets/icons/receive.svg';
|
||||
import { Card, LoadingSpinner, SmallAmount, SmallHeader, VStack } from './layout';
|
||||
import { For, Match, ParentComponent, Suspense, Switch, createMemo, createResource, createSignal } from 'solid-js';
|
||||
import { useMegaStore } from '~/state/megaStore';
|
||||
import { MutinyInvoice } from '@mutinywallet/mutiny-wasm';
|
||||
import { prettyPrintTime } from '~/utils/prettyPrintTime';
|
||||
import { JsonModal } from '~/components/JsonModal';
|
||||
import mempoolTxUrl from '~/utils/mempoolTxUrl';
|
||||
|
||||
const THREE_COLUMNS = 'grid grid-cols-[auto,1fr,auto] gap-4 py-2 px-2 border-b border-neutral-800 last:border-b-0'
|
||||
const CENTER_COLUMN = 'min-w-0 overflow-hidden max-w-full'
|
||||
const MISSING_LABEL = 'py-1 px-2 bg-m-red rounded inline-block text-sm'
|
||||
const RIGHT_COLUMN = 'flex flex-col items-right text-right'
|
||||
|
||||
type OnChainTx = {
|
||||
txid: string
|
||||
received: number
|
||||
sent: number
|
||||
fee?: number
|
||||
confirmation_time?: {
|
||||
height: number
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
type Utxo = {
|
||||
outpoint: string
|
||||
txout: {
|
||||
value: number
|
||||
script_pubkey: string
|
||||
}
|
||||
keychain: string
|
||||
is_spent: boolean
|
||||
}
|
||||
|
||||
const SubtleText: ParentComponent = (props) => {
|
||||
return <h3 class='text-xs text-gray-500 uppercase'>{props.children}</h3>
|
||||
}
|
||||
|
||||
function OnChainItem(props: { item: OnChainTx }) {
|
||||
const isReceive = createMemo(() => props.item.received > 0);
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonModal open={open()} data={props.item} title="On-Chain Transaction" setOpen={setOpen}>
|
||||
<a href={mempoolTxUrl(props.item.txid, "signet")} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
</JsonModal>
|
||||
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
|
||||
{isReceive() ? <img src={receive} alt="receive arrow" /> : <img src={send} alt="send arrow" />}
|
||||
<div class={CENTER_COLUMN}>
|
||||
<h2 class={MISSING_LABEL}>Label Missing</h2>
|
||||
{isReceive() ? <SmallAmount amount={props.item.received} /> : <SmallAmount amount={props.item.sent} />}
|
||||
{/* <h2 class="truncate">Txid: {props.item.txid}</h2> */}
|
||||
</div>
|
||||
<div class={RIGHT_COLUMN}>
|
||||
<SmallHeader class={isReceive() ? "text-m-green" : "text-m-red"}>
|
||||
{isReceive() ? "RECEIVE" : "SEND"}
|
||||
</SmallHeader>
|
||||
<SubtleText>{props.item.confirmation_time ? prettyPrintTime(props.item.confirmation_time.timestamp) : "Unconfirmed"}</SubtleText>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InvoiceItem(props: { item: MutinyInvoice }) {
|
||||
const isSend = createMemo(() => props.item.is_send);
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonModal open={open()} data={props.item} title="Lightning Transaction" setOpen={setOpen} />
|
||||
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
|
||||
{isSend() ? <img src={send} alt="send arrow" /> : <img src={receive} alt="receive arrow" />}
|
||||
<div class={CENTER_COLUMN}>
|
||||
<h2 class={MISSING_LABEL}>Label Missing</h2>
|
||||
<SmallAmount amount={props.item.amount_sats || 0} />
|
||||
</div>
|
||||
<div class={RIGHT_COLUMN}>
|
||||
<SmallHeader class={isSend() ? "text-m-red" : "text-m-green"}>
|
||||
{isSend() ? "SEND" : "RECEIVE"}
|
||||
</SmallHeader>
|
||||
<SubtleText>{prettyPrintTime(Number(props.item.expire))}</SubtleText>
|
||||
</div>
|
||||
</div >
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Utxo(props: { item: Utxo }) {
|
||||
const spent = createMemo(() => props.item.is_spent);
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonModal open={open()} data={props.item} title="Unspent Transaction Output" setOpen={setOpen} />
|
||||
<div class={THREE_COLUMNS} onClick={() => setOpen(!open())}>
|
||||
<img src={receive} alt="receive arrow" />
|
||||
<div class={CENTER_COLUMN}>
|
||||
<h2 class={MISSING_LABEL}>Label Missing</h2>
|
||||
<SmallAmount amount={props.item.txout.value} />
|
||||
</div>
|
||||
<div class={RIGHT_COLUMN}>
|
||||
<SmallHeader class={spent() ? "text-m-red" : "text-m-green"}>
|
||||
{spent() ? "SPENT" : "UNSPENT"}
|
||||
</SmallHeader>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Activity() {
|
||||
const [state, _] = useMegaStore();
|
||||
|
||||
const getTransactions = async () => {
|
||||
console.log("Getting onchain txs");
|
||||
const txs = await state.node_manager?.list_onchain() as OnChainTx[];
|
||||
return txs.reverse();
|
||||
}
|
||||
|
||||
const getInvoices = async () => {
|
||||
console.log("Getting invoices");
|
||||
const invoices = await state.node_manager?.list_invoices() as MutinyInvoice[];
|
||||
return invoices.filter((inv) => inv.paid).reverse();
|
||||
}
|
||||
|
||||
const getUtXos = async () => {
|
||||
console.log("Getting utxos");
|
||||
const utxos = await state.node_manager?.list_utxos() as Utxo[];
|
||||
return utxos;
|
||||
}
|
||||
|
||||
const [transactions, { refetch: _refetchTransactions }] = createResource(getTransactions);
|
||||
const [invoices, { refetch: _refetchInvoices }] = createResource(getInvoices);
|
||||
const [utxos, { refetch: _refetchUtxos }] = createResource(getUtXos);
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<Suspense>
|
||||
<Card title="On-chain">
|
||||
<Switch>
|
||||
<Match when={transactions.loading}>
|
||||
<LoadingSpinner big />
|
||||
</Match>
|
||||
<Match when={transactions.state === "ready" && transactions().length === 0}>
|
||||
<code>No transactions (empty state)</code>
|
||||
</Match>
|
||||
<Match when={transactions.state === "ready" && transactions().length >= 0}>
|
||||
<For each={transactions()}>
|
||||
{(tx) =>
|
||||
<OnChainItem item={tx} />
|
||||
}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Card>
|
||||
<Card title="Lightning">
|
||||
<Switch>
|
||||
<Match when={invoices.loading}>
|
||||
<LoadingSpinner big />
|
||||
</Match>
|
||||
<Match when={invoices.state === "ready" && invoices().length === 0}>
|
||||
<code>No invoices (empty state)</code>
|
||||
</Match>
|
||||
<Match when={invoices.state === "ready" && invoices().length >= 0}>
|
||||
<For each={invoices()}>
|
||||
{(invoice) =>
|
||||
<InvoiceItem item={invoice} />
|
||||
}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Card>
|
||||
<Card title="UTXOs">
|
||||
<Switch>
|
||||
<Match when={utxos.loading}>
|
||||
<LoadingSpinner big />
|
||||
</Match>
|
||||
<Match when={utxos.state === "ready" && utxos().length === 0}>
|
||||
<code>No utxos (empty state)</code>
|
||||
</Match>
|
||||
<Match when={utxos.state === "ready" && utxos().length >= 0}>
|
||||
<For each={utxos()}>
|
||||
{(utxo) =>
|
||||
<Utxo item={utxo} />
|
||||
}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Card>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
}
|
||||
29
src/components/Amount.tsx
Normal file
29
src/components/Amount.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useMegaStore } from "~/state/megaStore"
|
||||
import { satsToUsd } from "~/utils/conversions"
|
||||
|
||||
function prettyPrintAmount(n?: number | bigint): string {
|
||||
if (!n || n.valueOf() === 0) {
|
||||
return "0"
|
||||
}
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean, loading?: boolean }) {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
const amountInUsd = () => satsToUsd(state.price, Number(props.amountSats) || 0, true)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-4xl font-light">
|
||||
{props.loading ? "..." : prettyPrintAmount(props.amountSats)} <span class='text-xl'>SATS</span>
|
||||
</h1>
|
||||
<Show when={props.showFiat}>
|
||||
<h2 class="text-xl font-light text-white/70" >
|
||||
≈ {props.loading ? "..." : amountInUsd()} <span class="text-sm">USD</span>
|
||||
</h2>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
src/components/AmountEditable.tsx
Normal file
175
src/components/AmountEditable.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { For, createMemo, createSignal } from 'solid-js';
|
||||
import { Button } from '~/components/layout';
|
||||
import { useMegaStore } from '~/state/megaStore';
|
||||
import { satsToUsd } from '~/utils/conversions';
|
||||
import { Amount } from './Amount';
|
||||
import { Dialog } from '@kobalte/core';
|
||||
|
||||
const CHARACTERS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", "DEL"];
|
||||
|
||||
function SingleDigitButton(props: { character: string, onClick: (c: string) => void }) {
|
||||
return (
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-white/10 active:bg-m-blue text-white text-4xl font-semi font-mono"
|
||||
onClick={() => props.onClick(props.character)}
|
||||
>
|
||||
{props.character}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmountEditable(props: { initialAmountSats: string, setAmountSats: (s: bigint) => void, onSave?: () => void }) {
|
||||
const [displayAmount, setDisplayAmount] = createSignal(props.initialAmountSats || "0");
|
||||
|
||||
let inputRef!: HTMLInputElement;
|
||||
|
||||
function handleCharacterInput(character: string) {
|
||||
if (character === "DEL") {
|
||||
setDisplayAmount(displayAmount().slice(0, -1));
|
||||
} else {
|
||||
if (displayAmount() === "0") {
|
||||
setDisplayAmount(character);
|
||||
} else {
|
||||
setDisplayAmount(displayAmount() + character);
|
||||
}
|
||||
}
|
||||
|
||||
// After a button press make sure we re-focus the input
|
||||
inputRef.focus()
|
||||
}
|
||||
|
||||
// making a "controlled" input is a known hard problem
|
||||
// https://github.com/solidjs/solid/discussions/416
|
||||
function handleHiddenInput(e: Event & {
|
||||
currentTarget: HTMLInputElement;
|
||||
target: HTMLInputElement;
|
||||
}) {
|
||||
// if the input is empty, set the display amount to 0
|
||||
if (e.target.value === "") {
|
||||
setDisplayAmount("0");
|
||||
return;
|
||||
}
|
||||
|
||||
// if the input starts with one or more 0s, remove them, unless the input is just 0
|
||||
if (e.target.value.startsWith("0") && e.target.value !== "0") {
|
||||
setDisplayAmount(e.target.value.replace(/^0+/, ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// if there's already a decimal point, don't allow another one
|
||||
if (e.target.value.includes(".") && e.target.value.endsWith(".")) {
|
||||
setDisplayAmount(e.target.value.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayAmount(e.target.value);
|
||||
}
|
||||
|
||||
// I tried to do this with cooler math but I think it gets confused between decimal and percent
|
||||
const scale = createMemo(() => {
|
||||
const chars = displayAmount().length;
|
||||
|
||||
if (chars > 9) {
|
||||
return "scale-90"
|
||||
} else if (chars > 8) {
|
||||
return "scale-95"
|
||||
} else if (chars > 7) {
|
||||
return "scale-100"
|
||||
} else if (chars > 6) {
|
||||
return "scale-105"
|
||||
} else if (chars > 5) {
|
||||
return "scale-110"
|
||||
} else if (chars > 4) {
|
||||
return "scale-125"
|
||||
} else {
|
||||
return "scale-150"
|
||||
}
|
||||
})
|
||||
|
||||
const prettyPrint = createMemo(() => {
|
||||
const parsed = Number(displayAmount());
|
||||
if (isNaN(parsed)) {
|
||||
return displayAmount();
|
||||
} else {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
})
|
||||
|
||||
// Fiat conversion
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
const amountInUsd = () => satsToUsd(state.price, Number(displayAmount()) || 0, true)
|
||||
|
||||
// What we're all here for in the first place: returning a value
|
||||
function handleSubmit() {
|
||||
// validate it's a number
|
||||
console.log("handling submit...");
|
||||
const number = Number(displayAmount());
|
||||
if (isNaN(number) || number < 0) {
|
||||
setDisplayAmount("0");
|
||||
inputRef.focus();
|
||||
return;
|
||||
} else {
|
||||
const bign = BigInt(displayAmount());
|
||||
props.setAmountSats(bign);
|
||||
// This is so the parent can focus the next input if it wants to
|
||||
if (props.onSave) {
|
||||
props.onSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
|
||||
const DIALOG_CONTENT = "h-screen-safe p-4 bg-gray/50 backdrop-blur-md bg-black/80"
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<div class="p-4 rounded-xl border-2 border-m-blue">
|
||||
<Amount amountSats={Number(displayAmount())} showFiat />
|
||||
</div>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
{/* <Dialog.Overlay class={OVERLAY} /> */}
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
{/* TODO: figure out how to submit on enter */}
|
||||
<input ref={el => inputRef = el}
|
||||
autofocus
|
||||
inputmode='none'
|
||||
type="text"
|
||||
class="opacity-0 absolute -z-10"
|
||||
value={displayAmount()}
|
||||
onInput={(e) => handleHiddenInput(e)}
|
||||
/>
|
||||
<div class="flex flex-col gap-4 max-w-[400px] mx-auto">
|
||||
<div class="p-4 bg-black rounded-xl flex flex-col gap-4 items-center justify-center">
|
||||
<h1 class={`font-light text-center transition-transform ease-out duration-300 text-6xl ${scale()}`}>
|
||||
{prettyPrint()} <span class='text-xl'>SATS</span>
|
||||
</h1>
|
||||
<h2 class="text-xl font-light text-white/70" >
|
||||
≈ {amountInUsd()} <span class="text-sm">USD</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 w-full flex-none">
|
||||
<For each={CHARACTERS}>
|
||||
{(character) => (
|
||||
<SingleDigitButton character={character} onClick={handleCharacterInput} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{/* TODO: this feels wrong */}
|
||||
<Dialog.CloseButton>
|
||||
<Button intent="inactive" class="w-full flex-none"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Set Amount
|
||||
</Button>
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { Match, Suspense, Switch, createEffect, createMemo, createResource, createSignal } from "solid-js";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { satsToUsd, usdToSats } from "~/utils/conversions";
|
||||
|
||||
export type AmountInputProps = {
|
||||
initialAmountSats: string;
|
||||
setAmountSats: (amount: string) => void;
|
||||
refSetter: (el: HTMLInputElement) => void;
|
||||
}
|
||||
|
||||
type ActiveCurrency = "usd" | "sats"
|
||||
|
||||
export function AmountInput(props: AmountInputProps) {
|
||||
// We need to keep a local amount state because we need to convert between sats and USD
|
||||
// But we should keep the parent state in sats
|
||||
const [localAmount, setLocalAmount] = createSignal(props.initialAmountSats || "0");
|
||||
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
async function getPrice() {
|
||||
return await state.node_manager?.get_bitcoin_price()
|
||||
}
|
||||
|
||||
const [activeCurrency, setActiveCurrency] = createSignal<ActiveCurrency>("sats")
|
||||
|
||||
const [price] = createResource(getPrice)
|
||||
|
||||
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(localAmount()) || 0, true))
|
||||
const amountInSats = createMemo(() => usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, true))
|
||||
|
||||
createEffect(() => {
|
||||
// When the local amount changes, update the parent state if we're in sats
|
||||
if (activeCurrency() === "sats") {
|
||||
props.setAmountSats(localAmount())
|
||||
} else {
|
||||
// If we're in USD, convert the amount to sats
|
||||
props.setAmountSats(usdToSats(price(), parseFloat(localAmount() || "0.00") || 0, false))
|
||||
}
|
||||
})
|
||||
|
||||
function toggleActiveCurrency() {
|
||||
if (activeCurrency() === "sats") {
|
||||
setActiveCurrency("usd")
|
||||
// Convert the current amount of sats to USD
|
||||
const usd = satsToUsd(price() || 0, parseInt(localAmount()) || 0, false)
|
||||
console.log(`converted ${localAmount()} sats to ${usd} USD`)
|
||||
setLocalAmount(usd);
|
||||
} else {
|
||||
setActiveCurrency("sats")
|
||||
// Convert the current amount of USD to sats
|
||||
const sats = usdToSats(price() || 0, parseInt(localAmount()) || 0, false)
|
||||
console.log(`converted ${localAmount()} usd to ${sats} sats`)
|
||||
setLocalAmount(sats)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="">
|
||||
<TextField.Root
|
||||
value={localAmount()}
|
||||
onValueChange={setLocalAmount}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<pre>{`Bitcoin is ${price()?.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}</pre>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Amount {activeCurrency() === "sats" ? "(sats)" : "(USD)"}</TextField.Label>
|
||||
<TextField.Input autofocus ref={(el) => props.refSetter(el)} inputmode={"decimal"} class="w-full p-2 rounded-lg text-black" />
|
||||
<Suspense>
|
||||
<Switch fallback={<div>Loading...</div>}>
|
||||
<Match when={price() && activeCurrency() === "sats"}>
|
||||
<pre>{`~${amountInUsd()}`}</pre>
|
||||
</Match>
|
||||
<Match when={price() && activeCurrency() === "usd"}>
|
||||
<pre>{`${amountInSats()} sats`}</pre>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</TextField.Root>
|
||||
<button type="button" onClick={toggleActiveCurrency}>🔀</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import logo from '~/assets/icons/mutiny-logo.svg';
|
||||
import { SafeArea } from "~/components/layout";
|
||||
import { DefaultMain, NodeManagerGuard, SafeArea } from "~/components/layout";
|
||||
import BalanceBox from "~/components/BalanceBox";
|
||||
import NavBar from "~/components/NavBar";
|
||||
|
||||
// TODO: use this reload prompt for real
|
||||
import ReloadPrompt from "~/components/Reload";
|
||||
import KitchenSink from './KitchenSink';
|
||||
import { Scan } from '~/assets/svg/Scan';
|
||||
import { A } from 'solid-start';
|
||||
import { Activity } from './Activity';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeArea>
|
||||
<main class='flex flex-col gap-4 py-8 px-4 max-w-[800px] mx-auto'>
|
||||
<header>
|
||||
<img src={logo} class="App-logo" alt="logo" />
|
||||
</header>
|
||||
<BalanceBox />
|
||||
<ReloadPrompt />
|
||||
<KitchenSink />
|
||||
{/* safety div */}
|
||||
<div class="h-32" />
|
||||
</main>
|
||||
<NavBar activeTab="home" />
|
||||
</SafeArea>
|
||||
<NodeManagerGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<header class="w-full flex justify-between items-center mt-4 mb-2">
|
||||
<img src={logo} class="h-10" alt="logo" />
|
||||
<A class="p-2 hover:bg-white/5 rounded-lg active:bg-m-blue" href="scanner"><Scan /></A>
|
||||
</header>
|
||||
<ReloadPrompt />
|
||||
<BalanceBox />
|
||||
<Activity />
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="home" />
|
||||
</SafeArea>
|
||||
</NodeManagerGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Motion, Presence } from "@motionone/solid";
|
||||
import { createResource, Show, Suspense } from "solid-js";
|
||||
|
||||
import { ButtonLink } from "~/components/layout";
|
||||
import { ButtonLink, FancyCard } from "~/components/layout";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { Amount } from "./Amount";
|
||||
|
||||
function prettyPrintAmount(n?: number | bigint): string {
|
||||
if (!n || n.valueOf() === 0) {
|
||||
@@ -11,63 +10,69 @@ function prettyPrintAmount(n?: number | bigint): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function SyncingIndicator() {
|
||||
return (
|
||||
<div class="box-border animate-pulse px-2 py-1 -my-1 bg-white/70 rounded text-xs uppercase text-black">Syncing</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BalanceBox() {
|
||||
const [state, _] = useMegaStore();
|
||||
|
||||
const fetchBalance = async () => {
|
||||
if (state.node_manager) {
|
||||
console.log("Refetching balance");
|
||||
await state.node_manager.sync();
|
||||
const balance = await state.node_manager.get_balance();
|
||||
return balance
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
const fetchOnchainBalance = async () => {
|
||||
console.log("Refetching onchain balance");
|
||||
const balance = await state.node_manager?.get_balance();
|
||||
return balance
|
||||
};
|
||||
|
||||
const [balance, { refetch: refetchBalance }] = createResource(fetchBalance);
|
||||
// TODO: it's hacky to do these separately, but ln doesn't need the sync so I don't want to wait
|
||||
const fetchLnBalance = async () => {
|
||||
console.log("Refetching ln balance");
|
||||
const balance = await state.node_manager?.get_balance();
|
||||
return balance
|
||||
};
|
||||
|
||||
const [onChainBalance, { refetch: refetchOnChainBalance }] = createResource(fetchOnchainBalance);
|
||||
const [lnBalance, { refetch: refetchLnBalance }] = createResource(fetchLnBalance);
|
||||
|
||||
function refetchBalance() {
|
||||
refetchLnBalance();
|
||||
refetchOnChainBalance();
|
||||
}
|
||||
|
||||
return (
|
||||
<Presence>
|
||||
<Motion
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, easing: [0.87, 0, 0.13, 1] }}
|
||||
>
|
||||
<div class='border border-white rounded-xl border-b-4 p-4 flex flex-col gap-2'>
|
||||
<header class='text-sm font-semibold uppercase'>
|
||||
Balance
|
||||
</header>
|
||||
<>
|
||||
<FancyCard title="Lightning">
|
||||
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
|
||||
<Show when={lnBalance()}>
|
||||
<Amount amountSats={lnBalance()?.lightning} showFiat />
|
||||
</Show>
|
||||
</Suspense>
|
||||
</FancyCard>
|
||||
|
||||
<FancyCard title="On-Chain" tag={onChainBalance.loading && <SyncingIndicator />}>
|
||||
<Suspense fallback={<Amount amountSats={0} showFiat loading={true} />}>
|
||||
<div onClick={refetchBalance}>
|
||||
<h1 class='text-4xl font-light'>
|
||||
<Suspense fallback={"..."}>
|
||||
<Show when={balance()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
{prettyPrintAmount(balance()?.confirmed)} <span class='text-xl'>SAT</span>
|
||||
</div>
|
||||
<Show when={balance()?.unconfirmed}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<header class='text-sm font-semibold uppercase text-white/50'>
|
||||
Unconfirmed Balance
|
||||
</header>
|
||||
<div class="text-white/50">
|
||||
{prettyPrintAmount(balance()?.unconfirmed)} <span class='text-xl'>SAT</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</h1>
|
||||
<Amount amountSats={onChainBalance()?.confirmed} showFiat loading={onChainBalance.loading} />
|
||||
</div>
|
||||
<div class="flex gap-2 py-4">
|
||||
<ButtonLink href="/scanner" intent="green">Send</ButtonLink>
|
||||
<ButtonLink href="/receive" intent="blue">Receive</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</Motion>
|
||||
</Presence>
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<Show when={onChainBalance()?.unconfirmed}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<header class='text-sm font-semibold uppercase text-white/50'>
|
||||
Unconfirmed Balance
|
||||
</header>
|
||||
<div class="text-white/50">
|
||||
{prettyPrintAmount(onChainBalance()?.unconfirmed)} <span class='text-sm'>SATS</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</FancyCard>
|
||||
<div class="flex gap-2 py-4">
|
||||
<ButtonLink href="/send" intent="green">Send</ButtonLink>
|
||||
<ButtonLink href="/receive" intent="blue">Receive</ButtonLink>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
50
src/components/DeleteEverything.tsx
Normal file
50
src/components/DeleteEverything.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { ConfirmDialog } from "~/components/Dialog";
|
||||
import { Button } from "~/components/layout";
|
||||
import { showToast } from "~/components/Toaster";
|
||||
|
||||
export function deleteDb(name: string) {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = function () {
|
||||
console.log("Deleted database successfully");
|
||||
showToast({ title: "Deleted", description: `Deleted "${name}" database successfully` })
|
||||
};
|
||||
req.onerror = function () {
|
||||
console.error("Couldn't delete database");
|
||||
showToast(new Error("Couldn't delete database"))
|
||||
};
|
||||
req.onblocked = function () {
|
||||
console.error("Couldn't delete database due to the operation being blocked");
|
||||
showToast(new Error("Couldn't delete database due to the operation being blocked"))
|
||||
};
|
||||
}
|
||||
|
||||
export function DeleteEverything() {
|
||||
async function resetNode() {
|
||||
setConfirmLoading(true);
|
||||
deleteDb("gossip")
|
||||
localStorage.clear();
|
||||
showToast({ title: "Deleted", description: `Deleted all data` })
|
||||
setConfirmOpen(false);
|
||||
setConfirmLoading(false);
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
setConfirmOpen(true);
|
||||
}
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
const [confirmLoading, setConfirmLoading] = createSignal(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={confirmReset}>Delete Everything</Button>
|
||||
<ConfirmDialog loading={confirmLoading()} isOpen={confirmOpen()} onConfirm={resetNode} onCancel={() => setConfirmOpen(false)}>
|
||||
This will delete your node's state. This can't be undone!
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
32
src/components/Dialog.tsx
Normal file
32
src/components/Dialog.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { ParentComponent } from "solid-js";
|
||||
import { Button, SmallHeader } from "./layout";
|
||||
|
||||
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
|
||||
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
|
||||
|
||||
// TODO: implement this like toast so it's just one global confirm and I can call it with `confirm({ title: "Are you sure?", description: "This will delete your node" })`
|
||||
export const ConfirmDialog: ParentComponent<{ isOpen: boolean; loading: boolean; onCancel: () => void, onConfirm: () => void }> = (props) => {
|
||||
return (
|
||||
<Dialog.Root isOpen={props.isOpen} onOpenChange={props.onCancel}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="flex justify-between mb-2">
|
||||
<Dialog.Title><SmallHeader>Are you sure?</SmallHeader></Dialog.Title>
|
||||
</div>
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
{props.children}
|
||||
<div class="flex gap-4 w-full justify-end">
|
||||
<Button onClick={props.onCancel}>Cancel</Button>
|
||||
<Button intent="red" onClick={props.onConfirm} loading={props.loading} disabled={props.loading}>Confirm</Button>
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
)
|
||||
}
|
||||
46
src/components/JsonModal.tsx
Normal file
46
src/components/JsonModal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { JSX, createMemo } from "solid-js";
|
||||
import { Button, SmallHeader } from "~/components/layout";
|
||||
import { useCopy } from "~/utils/useCopy";
|
||||
|
||||
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
|
||||
const DIALOG_CONTENT = "max-w-[600px] max-h-screen-safe p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
|
||||
|
||||
export function JsonModal(props: { title: string, open: boolean, data?: unknown, setOpen: (open: boolean) => void, children?: JSX.Element }) {
|
||||
const json = createMemo(() => JSON.stringify(props.data, null, 2));
|
||||
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
return (
|
||||
<Dialog.Root isOpen={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="flex justify-between mb-2">
|
||||
<Dialog.Title>
|
||||
<SmallHeader>
|
||||
{props.title}
|
||||
</SmallHeader>
|
||||
</Dialog.Title>
|
||||
<Dialog.CloseButton>
|
||||
<code>X</code>
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
<div class="bg-white/10 rounded-xl max-h-[50vh] overflow-y-scroll disable-scrollbars p-4">
|
||||
<pre class="whitespace-pre-wrap break-all">
|
||||
{json()}
|
||||
</pre>
|
||||
</div>
|
||||
{props.children}
|
||||
<Button onClick={(_) => copy(json() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
|
||||
<Button onClick={(_) => props.setOpen(false)}>Close</Button>
|
||||
</Dialog.Description>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,50 @@
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { Card, Hr, SmallHeader, Button } from "~/components/layout";
|
||||
import { Card, Hr, SmallHeader, Button, InnerCard, VStack } from "~/components/layout";
|
||||
import PeerConnectModal from "~/components/PeerConnectModal";
|
||||
import { For, Show, Suspense, createResource, createSignal } from "solid-js";
|
||||
import { MutinyChannel, MutinyPeer } from "@mutinywallet/node-manager";
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { For, Show, Suspense, createEffect, createResource, createSignal, onCleanup } from "solid-js";
|
||||
import { MutinyChannel, MutinyPeer } from "@mutinywallet/mutiny-wasm";
|
||||
import { Collapsible, TextField } from "@kobalte/core";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import eify from "~/utils/eify";
|
||||
import { ConfirmDialog } from "./Dialog";
|
||||
import { showToast } from "./Toaster";
|
||||
|
||||
// TODO: hopefully I don't have to maintain this type forever but I don't know how to pass it around otherwise
|
||||
type RefetchPeersType = (info?: unknown) => MutinyPeer[] | Promise<MutinyPeer[] | undefined> | null | undefined
|
||||
|
||||
function PeerItem(props: { peer: MutinyPeer }) {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
const handleDisconnectPeer = async () => {
|
||||
const nodes = await state.node_manager?.list_nodes();
|
||||
const firstNode = nodes[0] as string || ""
|
||||
|
||||
if (props.peer.is_connected) {
|
||||
await state.node_manager?.disconnect_peer(firstNode, props.peer.pubkey);
|
||||
} else {
|
||||
await state.node_manager?.delete_peer(firstNode, props.peer.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root>
|
||||
<Collapsible.Trigger class="w-full">
|
||||
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
|
||||
{">"} {props.peer.alias ? props.peer.alias : props.peer.pubkey}
|
||||
</h2>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<VStack>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(props.peer, null, 2)}
|
||||
</pre>
|
||||
<Button intent="glowy" layout="xs" onClick={handleDisconnectPeer}>Disconnect</Button>
|
||||
</VStack>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function PeersList() {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
@@ -19,6 +54,16 @@ function PeersList() {
|
||||
|
||||
const [peers, { refetch }] = createResource(getPeers);
|
||||
|
||||
createEffect(() => {
|
||||
// refetch peers every 5 seconds
|
||||
const interval = setTimeout(() => {
|
||||
refetch();
|
||||
}, 5000);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<SmallHeader>
|
||||
@@ -26,13 +71,13 @@ function PeersList() {
|
||||
</SmallHeader>
|
||||
{/* By wrapping this in a suspense I don't cause the page to jump to the top */}
|
||||
<Suspense>
|
||||
<For each={peers()} fallback={<code>No peers</code>}>
|
||||
{(peer) => (
|
||||
<pre class="overflow-x-auto whitespace-pre-line break-all">
|
||||
{JSON.stringify(peer, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</For>
|
||||
<VStack>
|
||||
<For each={peers()} fallback={<code>No peers</code>}>
|
||||
{(peer) => (
|
||||
<PeerItem peer={peer} />
|
||||
)}
|
||||
</For>
|
||||
</VStack>
|
||||
</Suspense>
|
||||
<Button layout="small" onClick={refetch}>Refresh Peers</Button>
|
||||
<ConnectPeer refetchPeers={refetch} />
|
||||
@@ -60,25 +105,75 @@ function ConnectPeer(props: { refetchPeers: RefetchPeersType }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
<TextField.Root
|
||||
value={value()}
|
||||
onValueChange={setValue}
|
||||
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Connect Peer</TextField.Label>
|
||||
<TextField.Input class="w-full p-2 rounded-lg text-black" placeholder="mutiny:028241..." />
|
||||
<TextField.ErrorMessage class="text-red-500">Expecting something like mutiny:abc123...</TextField.ErrorMessage>
|
||||
</TextField.Root>
|
||||
<Button layout="small" type="submit">Connect</Button>
|
||||
</form >
|
||||
<InnerCard>
|
||||
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
<TextField.Root
|
||||
value={value()}
|
||||
onValueChange={setValue}
|
||||
validationState={(value() == "" || value().startsWith("mutiny:")) ? "valid" : "invalid"}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Connect Peer</TextField.Label>
|
||||
<TextField.Input class="w-full p-2 rounded-lg text-black" placeholder="mutiny:028241..." />
|
||||
<TextField.ErrorMessage class="text-red-500">Expecting something like mutiny:abc123...</TextField.ErrorMessage>
|
||||
</TextField.Root>
|
||||
<Button layout="small" type="submit">Connect</Button>
|
||||
</form >
|
||||
</InnerCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type RefetchChannelsListType = (info?: unknown) => MutinyChannel[] | Promise<MutinyChannel[] | undefined> | null | undefined
|
||||
|
||||
function ChannelItem(props: { channel: MutinyChannel, network?: string }) {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
const [confirmLoading, setConfirmLoading] = createSignal(false);
|
||||
|
||||
function handleCloseChannel() {
|
||||
setConfirmOpen(true);
|
||||
}
|
||||
|
||||
async function confirmCloseChannel() {
|
||||
setConfirmLoading(true);
|
||||
try {
|
||||
await state.node_manager?.close_channel(props.channel.outpoint as string)
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(eify(e));
|
||||
}
|
||||
setConfirmLoading(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible.Root>
|
||||
<Collapsible.Trigger class="w-full">
|
||||
<h2 class="truncate text-start text-lg font-mono bg-neutral-200 text-black rounded px-4 py-2">
|
||||
{">"} {props.channel.peer}
|
||||
</h2>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<VStack>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(props.channel, null, 2)}
|
||||
</pre>
|
||||
<a class="" href={mempoolTxUrl(props.channel.outpoint?.split(":")[0], props.network)} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
<Button intent="glowy" layout="xs" onClick={handleCloseChannel}>Close Channel</Button>
|
||||
|
||||
</VStack>
|
||||
<ConfirmDialog isOpen={confirmOpen()} onConfirm={confirmCloseChannel} onCancel={() => setConfirmOpen(false)} loading={confirmLoading()}>
|
||||
<p>Are you sure you want to close this channel?</p>
|
||||
</ConfirmDialog>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelsList() {
|
||||
const [state, _] = useMegaStore()
|
||||
|
||||
@@ -88,6 +183,16 @@ function ChannelsList() {
|
||||
|
||||
const [channels, { refetch }] = createResource(getChannels);
|
||||
|
||||
createEffect(() => {
|
||||
// refetch channels every 5 seconds
|
||||
const interval = setTimeout(() => {
|
||||
refetch();
|
||||
}, 5000);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
})
|
||||
|
||||
const network = state.node_manager?.get_network();
|
||||
|
||||
return (
|
||||
@@ -99,14 +204,7 @@ function ChannelsList() {
|
||||
<Suspense>
|
||||
<For each={channels()} fallback={<code>No channels</code>}>
|
||||
{(channel) => (
|
||||
<>
|
||||
<pre class="overflow-x-auto whitespace-pre-line break-all">
|
||||
{JSON.stringify(channel, null, 2)}
|
||||
</pre>
|
||||
<a class="text-sm font-light opacity-50 mt-2" href={mempoolTxUrl(channel.outpoint?.split(":")[0], network)} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
</>
|
||||
<ChannelItem channel={channel} network={network} />
|
||||
)}
|
||||
|
||||
</For>
|
||||
@@ -156,29 +254,31 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
<TextField.Root
|
||||
value={peerPubkey()}
|
||||
onValueChange={setPeerPubkey}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Pubkey</TextField.Label>
|
||||
<TextField.Input class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<TextField.Root
|
||||
value={amount()}
|
||||
onValueChange={setAmount}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Amount</TextField.Label>
|
||||
<TextField.Input
|
||||
type="number"
|
||||
class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<Button layout="small" type="submit">Open Channel</Button>
|
||||
</form >
|
||||
<InnerCard>
|
||||
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
<TextField.Root
|
||||
value={peerPubkey()}
|
||||
onValueChange={setPeerPubkey}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Pubkey</TextField.Label>
|
||||
<TextField.Input class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<TextField.Root
|
||||
value={amount()}
|
||||
onValueChange={setAmount}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Amount</TextField.Label>
|
||||
<TextField.Input
|
||||
type="number"
|
||||
class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<Button layout="small" type="submit">Open Channel</Button>
|
||||
</form >
|
||||
</InnerCard>
|
||||
<Show when={newChannel()}>
|
||||
<pre class="overflow-x-auto whitespace-pre-line break-all">
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(newChannel()?.outpoint, null, 2)}
|
||||
</pre>
|
||||
<pre>{newChannel()?.outpoint}</pre>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import mutiny_m from '~/assets/icons/m.svg';
|
||||
import scan from '~/assets/icons/scan.svg';
|
||||
// import scan from '~/assets/icons/scan.svg';
|
||||
import airplane from '~/assets/icons/airplane.svg';
|
||||
import settings from '~/assets/icons/settings.svg';
|
||||
|
||||
import { A } from "solid-start";
|
||||
|
||||
type ActiveTab = 'home' | 'scan' | 'settings' | 'none';
|
||||
type ActiveTab = 'home' | 'scan' | 'send' | 'settings' | 'none';
|
||||
|
||||
export default function NavBar(props: { activeTab: ActiveTab }) {
|
||||
const activeStyle = 'h-full border-t-2 border-b-2 border-b-black flex flex-col justify-center md:border-t-0 md:border-b-0 md:p-2 md:bg-white/20 md:rounded-xl'
|
||||
const inactiveStyle = "md:p-2 md:hover:bg-white/10 md:rounded-xl"
|
||||
const activeStyle = 'h-full border-t-2 border-b-2 border-b-sidebar-gray flex flex-col justify-center md:border-t-0 md:border-b-0 md:p-2 md:bg-black md:rounded-lg'
|
||||
const inactiveStyle = "md:p-2 md:hover:bg-white/5 md:rounded-lg md:active:bg-m-blue"
|
||||
return (
|
||||
<nav class='bg-black fixed bottom-0 shadow-lg z-40 w-full safe-bottom md:top-0 md:bottom-auto md:left-0 md:w-auto md:h-full'>
|
||||
<nav class='backdrop-blur-xl fixed bottom-0 md:shadow-none shadow-above z-40 w-full safe-bottom md:top-0 md:bottom-auto md:left-0 md:w-auto md:h-full'>
|
||||
<ul class='h-16 flex justify-between px-16 items-center md:flex-col md:justify-start md:gap-4 md:px-4 md:mt-4'>
|
||||
<li class={props.activeTab === "home" ? activeStyle : inactiveStyle}>
|
||||
<A href="/">
|
||||
<img src={mutiny_m} alt="home" />
|
||||
</A>
|
||||
</li>
|
||||
<li class={props.activeTab === "scan" ? activeStyle : inactiveStyle}>
|
||||
<A href="/scanner">
|
||||
<img src={scan} alt="scan" />
|
||||
<li class={props.activeTab === "send" ? activeStyle : inactiveStyle}>
|
||||
<A href="/send">
|
||||
<img src={airplane} alt="send" />
|
||||
</A>
|
||||
</li>
|
||||
<li class={props.activeTab === "settings" ? activeStyle : inactiveStyle}>
|
||||
|
||||
36
src/components/ReceiveSuccessModal.tsx
Normal file
36
src/components/ReceiveSuccessModal.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { JSX } from "solid-js";
|
||||
import { Button, SmallHeader } from "~/components/layout";
|
||||
import close from "~/assets/icons/close.svg";
|
||||
|
||||
const DIALOG_POSITIONER = "fixed inset-0 safe-top safe-bottom z-50"
|
||||
const DIALOG_CONTENT = "h-screen-safe p-4 bg-gray/50 backdrop-blur-md bg-black/80"
|
||||
|
||||
|
||||
export function ReceiveSuccessModal(props: { title: string, open: boolean, setOpen: (open: boolean) => void, children?: JSX.Element }) {
|
||||
return (
|
||||
<Dialog.Root isOpen={props.open} onOpenChange={(isOpen) => props.setOpen(isOpen)}>
|
||||
<Dialog.Portal>
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<Dialog.Title>
|
||||
<SmallHeader>
|
||||
{props.title}
|
||||
</SmallHeader>
|
||||
</Dialog.Title>
|
||||
<Dialog.CloseButton class="p-2 hover:bg-white/10 rounded-lg active:bg-m-blue">
|
||||
<img src={close} alt="Close" />
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
{props.children}
|
||||
<Button onClick={(_) => props.setOpen(false)}>Nice</Button>
|
||||
</Dialog.Description>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
)
|
||||
}
|
||||
27
src/components/SeedWords.tsx
Normal file
27
src/components/SeedWords.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Match, Switch, createSignal } from "solid-js"
|
||||
|
||||
export function SeedWords(props: { words: string }) {
|
||||
const [shouldShow, setShouldShow] = createSignal(false)
|
||||
|
||||
function toggleShow() {
|
||||
setShouldShow(!shouldShow())
|
||||
}
|
||||
|
||||
return (<pre class="flex items-center gap-4 bg-m-red p-4 rounded-xl overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={!shouldShow()}>
|
||||
<div onClick={toggleShow} class="cursor-pointer">
|
||||
<code class="text-red">TAP TO REVEAL SEED WORDS</code>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={shouldShow()}>
|
||||
<div onClick={toggleShow} class="cursor-pointer overflow-hidden">
|
||||
<p class="font-mono w-full whitespace-pre-wrap">
|
||||
{props.words}
|
||||
</p>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</pre >)
|
||||
}
|
||||
37
src/components/Sent.tsx
Normal file
37
src/components/Sent.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { ButtonLink, SmallHeader } from "~/components/layout";
|
||||
import close from "~/assets/icons/close.svg";
|
||||
|
||||
const OVERLAY = "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
const DIALOG_POSITIONER = "fixed inset-0 z-50 flex items-center justify-center"
|
||||
const DIALOG_CONTENT = "w-[80vw] max-w-[400px] p-4 bg-gray/50 backdrop-blur-md shadow-xl rounded-xl border border-white/10"
|
||||
|
||||
export function SentModal(props: { details?: { nice: string } }) {
|
||||
return (
|
||||
<Dialog.Root isOpen={!!props.details}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={OVERLAY} />
|
||||
<div class={DIALOG_POSITIONER}>
|
||||
<Dialog.Content class={DIALOG_CONTENT}>
|
||||
<div class="flex justify-between mb-2 items-center">
|
||||
<Dialog.Title>
|
||||
<SmallHeader>
|
||||
Sent!
|
||||
</SmallHeader>
|
||||
</Dialog.Title>
|
||||
<Dialog.CloseButton class="p-2 hover:bg-white/10 rounded-lg active:bg-m-blue">
|
||||
<img src={close} alt="Close" />
|
||||
</Dialog.CloseButton>
|
||||
</div>
|
||||
<Dialog.Description class="flex flex-col gap-4">
|
||||
<pre>
|
||||
{JSON.stringify(props.details)}
|
||||
</pre>
|
||||
<ButtonLink href="/">Nice</ButtonLink>
|
||||
</Dialog.Description>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root >
|
||||
)
|
||||
}
|
||||
100
src/components/SettingsStringsEditor.tsx
Normal file
100
src/components/SettingsStringsEditor.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createForm, url } from '@modular-forms/solid';
|
||||
import { TextField } from '~/components/layout/TextField';
|
||||
import { NodeManagerSettingStrings, getExistingSettings } from '~/logic/nodeManagerSetup';
|
||||
import { Button } from '~/components/layout';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { deleteDb } from '~/components/DeleteEverything';
|
||||
import { showToast } from './Toaster';
|
||||
import eify from '~/utils/eify';
|
||||
import { ConfirmDialog } from "~/components/Dialog";
|
||||
import { useMegaStore } from '~/state/megaStore';
|
||||
|
||||
export function SettingsStringsEditor() {
|
||||
const existingSettings = getExistingSettings();
|
||||
const [_settingsForm, { Form, Field }] = createForm<NodeManagerSettingStrings>({ initialValues: existingSettings });
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
|
||||
const [settingsTemp, setSettingsTemp] = createSignal<NodeManagerSettingStrings>();
|
||||
|
||||
const [_store, actions] = useMegaStore();
|
||||
|
||||
async function handleSubmit(values: NodeManagerSettingStrings) {
|
||||
try {
|
||||
const existing = getExistingSettings();
|
||||
const newSettings = { ...existing, ...values }
|
||||
if (existing.network !== values.network) {
|
||||
// If the network changes we need to confirm the wipe
|
||||
// Save the settings so we can get them later
|
||||
setSettingsTemp(newSettings);
|
||||
setConfirmOpen(true);
|
||||
} else {
|
||||
await actions.setupNodeManager(newSettings);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showToast(eify(e))
|
||||
}
|
||||
console.log(values)
|
||||
}
|
||||
|
||||
async function confirmStateReset() {
|
||||
try {
|
||||
deleteDb("gossip")
|
||||
localStorage.clear();
|
||||
showToast({ title: "Deleted", description: `Deleted all data` })
|
||||
const loadedValues = settingsTemp();
|
||||
|
||||
await actions.setupNodeManager(loadedValues);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
showToast(eify(e))
|
||||
}
|
||||
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
|
||||
return <Form onSubmit={handleSubmit} class="flex flex-col gap-4">
|
||||
<ConfirmDialog loading={false} isOpen={confirmOpen()} onConfirm={confirmStateReset} onCancel={() => setConfirmOpen(false)}>
|
||||
Are you sure? Changing networks will delete your node's state. This can't be undone!
|
||||
</ConfirmDialog>
|
||||
<h2 class="text-2xl font-light">Don't trust us! Use your own servers to back Mutiny.</h2>
|
||||
<Field name="network">
|
||||
{(field, props) => (
|
||||
// TODO: make a cool select component
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-semibold uppercase">Network</label>
|
||||
<select {...field} {...props} class="bg-black rounded-xl border border-white px-4 py-2">
|
||||
<option value="mainnet">Mainnet</option>
|
||||
<option value="testnet">Testnet</option>
|
||||
<option value="regtest">Regtest</option>
|
||||
<option value="signet">Signet</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="proxy" validate={[url("Should be a url starting with wss://")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="Websockets Proxy" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="esplora" validate={[url("That doesn't look like a URL")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="Esplora" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="rgs" validate={[url("That doesn't look like a URL")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="RGS" />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="lsp" validate={[url("That doesn't look like a URL")]}>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="LSP" />
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit">Save</Button>
|
||||
</Form>
|
||||
|
||||
}
|
||||
55
src/components/Toaster.tsx
Normal file
55
src/components/Toaster.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Toast, toaster } from "@kobalte/core";
|
||||
import { Portal } from "solid-js/web";
|
||||
import close from "~/assets/icons/close.svg";
|
||||
import { SmallHeader } from "./layout";
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<Portal>
|
||||
<Toast.Region class="top-0 fixed flex gap-4 w-full justify-center safe-top safe-left safe-right safe-bottom">
|
||||
<Toast.List class="z-[9999] max-w-[100vw] w-[400px] mt-8 flex flex-col gap-4" />
|
||||
</Toast.Region>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastArg = { title: string, description: string } | Error
|
||||
|
||||
export function showToast(arg: ToastArg) {
|
||||
if (arg instanceof Error) {
|
||||
return toaster.show(props => (
|
||||
<ToastItem title="Error" description={arg.message} isError {...props} />
|
||||
))
|
||||
} else {
|
||||
return toaster.show(props => (
|
||||
<ToastItem title={arg.title} description={arg.description} {...props} />
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
export function ToastItem(props: { toastId: number, title: string, description: string, isError?: boolean }) {
|
||||
return (
|
||||
<Toast.Root toastId={props.toastId} class={`w-[80vw] max-w-[400px] p-4 bg-neutral-900/80 backdrop-blur-md shadow-xl rounded-xl border ${props.isError ? "border-m-red/50" : "border-white/10"} `}>
|
||||
<div class="flex gap-4 w-full justify-between">
|
||||
<div>
|
||||
<Toast.Title>
|
||||
<SmallHeader>
|
||||
{props.title}
|
||||
</SmallHeader>
|
||||
</Toast.Title>
|
||||
<Toast.Description>
|
||||
<p>
|
||||
{props.description}
|
||||
</p>
|
||||
</Toast.Description>
|
||||
</div>
|
||||
<Toast.CloseButton class="hover:bg-white/10 rounded-lg active:bg-m-blue">
|
||||
<img src={close} alt="Close" />
|
||||
</Toast.CloseButton>
|
||||
</div>
|
||||
{/* <Toast.ProgressTrack class="toast__progress-track">
|
||||
<Toast.ProgressFill class="toast__progress-fill" />
|
||||
</Toast.ProgressTrack> */}
|
||||
</Toast.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { children, JSX, ParentComponent, splitProps } from "solid-js";
|
||||
import { children, JSX, ParentComponent, Show, splitProps } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import { A } from "solid-start";
|
||||
import { LoadingSpinner } from ".";
|
||||
|
||||
const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
|
||||
const button = cva("p-3 rounded-xl text-xl font-semibold disabled:opacity-50 disabled:grayscale transition", {
|
||||
variants: {
|
||||
// TODO: button hover has to work different than buttonlinks (like disabled state)
|
||||
intent: {
|
||||
active: "bg-white text-black",
|
||||
inactive: "bg-black text-white border border-white disabled:opacity-50",
|
||||
blue: "bg-[#3B6CCC] text-white",
|
||||
red: "bg-[#F61D5B] text-white",
|
||||
green: "bg-[#1EA67F] text-white",
|
||||
active: "bg-white text-black border border-white hover:text-[#3B6CCC]",
|
||||
inactive: "bg-black text-white border border-white hover:text-[#3B6CCC]",
|
||||
glowy: "bg-black/10 shadow-xl text-white border border-m-blue hover:m-blue-dark hover:text-m-blue",
|
||||
blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button",
|
||||
red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button",
|
||||
green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button",
|
||||
},
|
||||
layout: {
|
||||
flex: "flex-1",
|
||||
pad: "px-8",
|
||||
small: "p-1 w-auto",
|
||||
small: "px-4 py-2 w-auto text-lg",
|
||||
xs: "px-2 py-1 w-auto rounded-lg font-normal text-base"
|
||||
},
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
intent: "inactive",
|
||||
layout: "flex"
|
||||
@@ -28,7 +31,9 @@ const button = cva(["p-4", "rounded-xl", "text-xl", "font-semibold"], {
|
||||
// Help from https://github.com/arpadgabor/credee/blob/main/packages/www/src/components/ui/button.tsx
|
||||
|
||||
type StyleProps = VariantProps<typeof button>
|
||||
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps { }
|
||||
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>, StyleProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const Button: ParentComponent<ButtonProps> = props => {
|
||||
const slot = children(() => props.children)
|
||||
@@ -43,8 +48,13 @@ export const Button: ParentComponent<ButtonProps> = props => {
|
||||
layout: local.layout,
|
||||
})}
|
||||
>
|
||||
{slot()}
|
||||
</button>
|
||||
<Show when={props.loading} fallback={slot()} >
|
||||
<div class="flex justify-center">
|
||||
{/* TODO: constrain this to the exact height of the button */}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Show>
|
||||
</button >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
30
src/components/layout/Radio.tsx
Normal file
30
src/components/layout/Radio.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { RadioGroup } from "@kobalte/core";
|
||||
import { For } from "solid-js";
|
||||
|
||||
type Choices = { value: string, label: string, caption: string }[]
|
||||
|
||||
// TODO: how could would it be if we could just pass the estimated fees in here?
|
||||
export function StyledRadioGroup(props: { value: string, choices: Choices, onValueChange: (value: string) => void }) {
|
||||
return (
|
||||
<RadioGroup.Root value={props.value} onValueChange={(e) => props.onValueChange(e)} class="grid w-full gap-4 grid-cols-2">
|
||||
<For each={props.choices}>
|
||||
{choice =>
|
||||
<RadioGroup.Item value={choice.value} class="ui-checked:bg-neutral-950 bg-white/10 rounded outline outline-black/50 ui-checked:outline-m-blue ui-checked:outline-2">
|
||||
<div class="py-3 px-4">
|
||||
<RadioGroup.ItemInput />
|
||||
<RadioGroup.ItemControl >
|
||||
<RadioGroup.ItemIndicator />
|
||||
</RadioGroup.ItemControl>
|
||||
<RadioGroup.ItemLabel class="ui-checked:text-white text-neutral-400">
|
||||
<div class="block">
|
||||
<div class="text-lg font-semibold">{choice.label}</div>
|
||||
<div class="text-sm font-light">{choice.caption}</div>
|
||||
</div>
|
||||
</RadioGroup.ItemLabel>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
}
|
||||
</For>
|
||||
</RadioGroup.Root>
|
||||
)
|
||||
}
|
||||
49
src/components/layout/TextField.tsx
Normal file
49
src/components/layout/TextField.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { TextField as KTextField } from '@kobalte/core';
|
||||
import { type JSX, Show, splitProps } from 'solid-js';
|
||||
|
||||
type TextFieldProps = {
|
||||
name: string;
|
||||
type?: 'text' | 'email' | 'tel' | 'password' | 'url' | 'date';
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value: string | undefined;
|
||||
error: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
ref: (element: HTMLInputElement | HTMLTextAreaElement) => void;
|
||||
onInput: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent>;
|
||||
};
|
||||
|
||||
export function TextField(props: TextFieldProps) {
|
||||
const [fieldProps] = splitProps(props, [
|
||||
'placeholder',
|
||||
'ref',
|
||||
'onInput',
|
||||
'onChange',
|
||||
'onBlur',
|
||||
]);
|
||||
return (
|
||||
<KTextField.Root
|
||||
class="flex flex-col gap-2"
|
||||
name={props.name}
|
||||
value={props.value}
|
||||
validationState={props.error ? 'invalid' : 'valid'}
|
||||
isRequired={props.required}
|
||||
>
|
||||
<Show when={props.label}>
|
||||
<KTextField.Label class="text-sm uppercase font-semibold">
|
||||
{props.label}
|
||||
</KTextField.Label>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.multiline}
|
||||
fallback={<KTextField.Input {...fieldProps} type={props.type} class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400" />}
|
||||
>
|
||||
<KTextField.TextArea {...fieldProps} autoResize class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400" />
|
||||
</Show>
|
||||
<KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage>
|
||||
</KTextField.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,83 @@
|
||||
import { ParentComponent, Show } from "solid-js"
|
||||
import { JSX, ParentComponent, Show, Suspense } from "solid-js"
|
||||
import Linkify from "./Linkify"
|
||||
import { Button, ButtonLink } from "./Button"
|
||||
import { Separator } from "@kobalte/core"
|
||||
import { useMegaStore } from "~/state/megaStore"
|
||||
|
||||
const SmallHeader: ParentComponent = (props) => <header class='text-sm font-semibold uppercase'>{props.children}</header>
|
||||
const SmallHeader: ParentComponent<{ class?: string }> = (props) => {
|
||||
return <header class={`text-sm font-semibold uppercase ${props.class}`}>{props.children}</header>
|
||||
}
|
||||
|
||||
const Card: ParentComponent<{ title?: string }> = (props) => {
|
||||
return (
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]'>
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-neutral-950 overflow-x-hidden'>
|
||||
{props.title && <SmallHeader>{props.title}</SmallHeader>}
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const SafeArea: ParentComponent<{ main?: boolean }> = (props) => {
|
||||
const InnerCard: ParentComponent<{ title?: string }> = (props) => {
|
||||
return (
|
||||
<div class="safe-top safe-left safe-right safe-bottom">
|
||||
<div class="disable-scrollbars max-h-screen h-full overflow-y-scroll md:pl-[8rem] md:pr-[6rem]">
|
||||
<Show when={props.main} fallback={props.children}>
|
||||
<main class='flex flex-col py-8 px-4 items-center'>
|
||||
{props.children}
|
||||
</main>
|
||||
</Show>
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 border border-white/10 bg-[rgba(255,255,255,0.05)]'>
|
||||
{props.title && <SmallHeader>{props.title}</SmallHeader>}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FancyCard: ParentComponent<{ title?: string, tag?: JSX.Element }> = (props) => {
|
||||
return (
|
||||
<div class='border border-black/50 rounded-xl border-b-4 p-4 flex flex-col gap-2 bg-neutral-800/50 shadow-fancy-card'>
|
||||
<div class="w-full flex justify-between items-center">
|
||||
{props.title && <SmallHeader>{props.title}</SmallHeader>}
|
||||
{props.tag && props.tag}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SafeArea: ParentComponent = (props) => {
|
||||
return (
|
||||
<div class="safe-top safe-left safe-right safe-bottom flex flex-col h-screen-safe">
|
||||
<div class="flex-1 disable-scrollbars overflow-y-scroll md:pl-[8rem] md:pr-[6rem]">
|
||||
{props.children}
|
||||
<div class="h-32" />
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
const LoadingSpinner = () => {
|
||||
return (<div role="status" class="w-full h-full grid" >
|
||||
const DefaultMain: ParentComponent = (props) => {
|
||||
return (
|
||||
<main class="w-full max-w-[600px] flex flex-col gap-4 mx-auto p-4">
|
||||
{props.children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function FullscreenLoader() {
|
||||
return (
|
||||
<div class="w-screen h-screen flex justify-center items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NodeManagerGuard: ParentComponent = (props) => {
|
||||
const [state, _] = useMegaStore();
|
||||
return (
|
||||
<Suspense fallback={<FullscreenLoader />}>
|
||||
<Show when={state.node_manager}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadingSpinner = (props: { big?: boolean }) => {
|
||||
return (<div role="status" class={props.big ? "w-full h-full grid" : ""} >
|
||||
<svg aria-hidden="true" class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-m-red place-self-center" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
|
||||
@@ -41,4 +88,33 @@ const LoadingSpinner = () => {
|
||||
|
||||
const Hr = () => <Separator.Root class="my-4 border-white/20" />
|
||||
|
||||
export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr }
|
||||
const LargeHeader: ParentComponent = (props) => {
|
||||
return (<h1 class="text-4xl font-semibold uppercase border-b-2 border-b-white my-4">{props.children}</h1>)
|
||||
}
|
||||
|
||||
const VStack: ParentComponent<{ biggap?: boolean }> = (props) => {
|
||||
return (<div class={`flex flex-col gap-${props.biggap ? "8" : "4"}`}>{props.children}</div>)
|
||||
}
|
||||
|
||||
const SmallAmount: ParentComponent<{ amount: number | bigint }> = (props) => {
|
||||
return (<h2 class="font-light text-lg">{props.amount.toLocaleString()} <span class="text-sm">SATS</span></h2>)
|
||||
}
|
||||
|
||||
export {
|
||||
SmallHeader,
|
||||
Card,
|
||||
SafeArea,
|
||||
LoadingSpinner,
|
||||
Button,
|
||||
ButtonLink,
|
||||
Linkify,
|
||||
Hr,
|
||||
NodeManagerGuard,
|
||||
FullscreenLoader,
|
||||
InnerCard,
|
||||
FancyCard,
|
||||
DefaultMain,
|
||||
LargeHeader,
|
||||
VStack,
|
||||
SmallAmount
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const relayUrls = [
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { LoadingSpinner } from "~/components/layout";
|
||||
import Notes from "~/components/waitlist/Notes";
|
||||
import logo from '~/assets/icons/mutiny-logo.svg';
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
@@ -34,14 +35,17 @@ export function WaitlistAlreadyIn() {
|
||||
const [posts] = createResource("", postsFetcher);
|
||||
|
||||
return (
|
||||
<main class='flex flex-col gap-2 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-center drop-shadow-blue-glow'>
|
||||
<main class='flex flex-col gap-2 sm:gap-4 py-8 px-4 max-w-xl mx-auto items-start drop-shadow-blue-glow'>
|
||||
<a href="https://mutinywallet.com">
|
||||
<img src={logo} class="h-10" alt="logo" />
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold">You're on a list!</h1>
|
||||
<h2 class="text-xl">
|
||||
We'll message you when Mutiny Wallet is ready.
|
||||
</h2>
|
||||
<div class="px-4 sm:px-8 py-8 rounded-xl bg-half-black">
|
||||
<div class="px-4 sm:px-8 py-8 rounded-xl bg-half-black w-full">
|
||||
<h2 class="text-sm font-semibold uppercase">Recent Updates</h2>
|
||||
<Show when={!posts.loading} fallback={<div class="h-[10rem]"><LoadingSpinner /></div>}>
|
||||
<Show when={!posts.loading} fallback={<div class="h-[10rem]"><LoadingSpinner big /></div>}>
|
||||
<Notes notes={posts() && posts() || []} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,116 +1,110 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { Button, LoadingSpinner } from "~/components/layout";
|
||||
|
||||
const INPUT = "w-full mb-4 p-2 rounded-lg text-black"
|
||||
import { Match, Switch, createSignal } from "solid-js";
|
||||
import { Button } from "~/components/layout";
|
||||
import { StyledRadioGroup } from "../layout/Radio";
|
||||
import { TextField } from "../layout/TextField";
|
||||
import { SubmitHandler, createForm, email, getValue, required, setValue } from "@modular-forms/solid";
|
||||
import { showToast } from "../Toaster";
|
||||
import eify from "~/utils/eify";
|
||||
import logo from '~/assets/icons/mutiny-logo.svg';
|
||||
|
||||
const WAITLIST_ENDPOINT = "https://waitlist.mutiny-waitlist.workers.dev/waitlist";
|
||||
|
||||
const COMMUNICATION_METHODS = [{ value: "nostr", label: "Nostr", caption: "Your freshest npub" }, { value: "email", label: "Email", caption: "Burners welcome" }]
|
||||
|
||||
type WaitlistForm = {
|
||||
user_type: "nostr" | "email",
|
||||
id: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
const initialValues: WaitlistForm = { user_type: "nostr", id: "", comment: "" };
|
||||
|
||||
export default function WaitlistForm() {
|
||||
const [nostr, setNostr] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | undefined>(undefined);
|
||||
const [waitlistForm, { Form, Field }] = createForm<WaitlistForm>({ initialValues });
|
||||
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
// Form submission function that takes the form data and sends it to the backend
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form as HTMLFormElement);
|
||||
const value = Object.fromEntries(data.entries());
|
||||
console.log(value);
|
||||
|
||||
let payload: null | { user_type: string, id: string, comment: string } = null;
|
||||
|
||||
if (nostr()) {
|
||||
payload = {
|
||||
user_type: "nostr",
|
||||
id: value.pubkey as string,
|
||||
comment: value.comments as string
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
user_type: "email",
|
||||
id: value.email as string,
|
||||
comment: value.comments as string
|
||||
}
|
||||
}
|
||||
|
||||
console.log(payload);
|
||||
const newHandleSubmit: SubmitHandler<WaitlistForm> = async (f: WaitlistForm) => {
|
||||
console.log(f);
|
||||
|
||||
// TODO: not sure why waitlistForm.submitting doesn't work for me
|
||||
// https://modularforms.dev/solid/guides/handle-submission
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!payload || !payload.id) {
|
||||
throw new Error("nope");
|
||||
}
|
||||
|
||||
const res = await fetch(WAITLIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(f)
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error("nope");
|
||||
} else {
|
||||
// On success set the id in local storage and reload the page
|
||||
localStorage.setItem('waitlist_id', payload.id);
|
||||
localStorage.setItem('waitlist_id', f.id);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (nostr()) {
|
||||
setError("Something went wrong. Are you sure that's a valid npub?");
|
||||
if (f.user_type === "nostr") {
|
||||
const error = new Error("Something went wrong. Are you sure that's a valid npub?")
|
||||
showToast(eify(error))
|
||||
|
||||
} else {
|
||||
setError("Something went wrong. Are you sure that's a valid email?");
|
||||
const error = new Error("Something went wrong. Not sure what.")
|
||||
showToast(eify(error))
|
||||
}
|
||||
setTimeout(() => setLoading(false), 1000);
|
||||
return
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main class='flex flex-col gap-4 py-8 px-4 max-w-xl mx-auto drop-shadow-blue-glow'>
|
||||
<main class='flex flex-col gap-8 py-8 px-4 max-w-xl mx-auto'>
|
||||
<a href="https://mutinywallet.com">
|
||||
<img src={logo} class="h-10" alt="logo" />
|
||||
</a>
|
||||
<h1 class='text-4xl font-bold'>Join Waitlist</h1>
|
||||
{/* HTML form with three inputs: nostr pubkey (text), email (text), and a textarea for comments */}
|
||||
<h2 class="text-xl">
|
||||
Sign up for our waitlist and we'll send a message when Mutiny Wallet is ready for you.
|
||||
</h2>
|
||||
<div class="p-8 rounded-xl bg-half-black">
|
||||
<div class="flex gap-4 mb-6">
|
||||
<Button intent={nostr() ? "active" : "inactive"} onClick={() => setNostr(true)}>Nostr</Button>
|
||||
<Button intent={nostr() ? "inactive" : "active"} onClick={() => setNostr(false)}> Email</Button>
|
||||
</div>
|
||||
{error() &&
|
||||
<div class="mb-6">
|
||||
<p class="text-m-red">Error: {error()}</p>
|
||||
</div>
|
||||
}
|
||||
<form class="flex flex-col items-start gap-2" onSubmit={handleSubmit}>
|
||||
{nostr() &&
|
||||
<>
|
||||
<label class="font-semibold" for="pubkey">Nostr npub or NIP-05</label>
|
||||
<input class={INPUT} type="text" id="pubkey" name="pubkey" placeholder="npub..." />
|
||||
</>
|
||||
}
|
||||
{
|
||||
!nostr() &&
|
||||
<>
|
||||
<label class="font-semibold" for="email">Email</label>
|
||||
<input class={INPUT} type="text" id="email" name="email" placeholder="email@mutinywallet.com" />
|
||||
</>
|
||||
}
|
||||
<label class="font-semibold" for="comments">Comments</label>
|
||||
<textarea class={INPUT} id="comments" name="comments" rows={4} placeholder="I want a lightning wallet that does..." />
|
||||
{loading() &&
|
||||
<LoadingSpinner />
|
||||
}
|
||||
{!loading() &&
|
||||
<Button intent="red" layout="pad" >Submit</Button>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
<Form onSubmit={newHandleSubmit} class="flex flex-col gap-8">
|
||||
<Field name="user_type">
|
||||
{(field, _props) => (
|
||||
// TODO: there's probably a "real" way to do this with modular-forms
|
||||
<StyledRadioGroup value={field.value || "nostr"} onValueChange={(newValue) => setValue(waitlistForm, "user_type", newValue as "nostr" | "email")} choices={COMMUNICATION_METHODS} />
|
||||
)}
|
||||
</Field>
|
||||
<Switch>
|
||||
<Match when={getValue(waitlistForm, 'user_type', { shouldActive: false }) === 'nostr'}>
|
||||
<Field name="id"
|
||||
validate={[required("We need some way to contact you")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="Nostr npub or NIP-05" placeholder="npub..." />
|
||||
)}
|
||||
</Field>
|
||||
</Match>
|
||||
<Match when={getValue(waitlistForm, 'user_type', { shouldActive: false }) === 'email'}>
|
||||
<Field name="id"
|
||||
validate={[required("We need some way to contact you"), email("That doesn't look like an email address to me")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<TextField {...props} value={field.value} error={field.error} label="Email" placeholder="email@nokycemail.com" />
|
||||
)}
|
||||
</Field>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Field name="comment">
|
||||
{(field, props) => (
|
||||
<TextField multiline {...props} value={field.value} error={field.error} label="Comments" placeholder="I want a lightning wallet that does..." />
|
||||
)}
|
||||
</Field>
|
||||
<Button loading={loading()} disabled={loading() || !waitlistForm.dirty || waitlistForm.submitting || waitlistForm.invalid} class="self-start" intent="red" type="submit" layout="pad">Submit</Button>
|
||||
</Form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,52 @@
|
||||
|
||||
import init, { NodeManager } from '@mutinywallet/node-manager';
|
||||
import init, { NodeManager } from '@mutinywallet/mutiny-wasm';
|
||||
|
||||
// export type NodeManagerSettingStrings = {
|
||||
// network?: string, proxy?: string, esplora?: string, rgs?: string, lsp?: string,
|
||||
// }
|
||||
|
||||
type Network = "mainnet" | "testnet" | "regtest" | "signet";
|
||||
export type NodeManagerSettingStrings = {
|
||||
network?: string, proxy?: string, esplora?: string
|
||||
network?: Network, proxy?: string, esplora?: string, rgs?: string, lsp?: string,
|
||||
}
|
||||
|
||||
export function getExistingSettings(): NodeManagerSettingStrings {
|
||||
const network = localStorage.getItem('MUTINY_SETTINGS_network') || import.meta.env.VITE_NETWORK;
|
||||
const proxy = localStorage.getItem('MUTINY_SETTINGS_proxy') || import.meta.env.VITE_PROXY;
|
||||
const esplora = localStorage.getItem('MUTINY_SETTINGS_esplora') || import.meta.env.VITE_ESPLORA;
|
||||
const rgs = localStorage.getItem('MUTINY_SETTINGS_rgs') || import.meta.env.VITE_RGS;
|
||||
const lsp = localStorage.getItem('MUTINY_SETTINGS_lsp') || import.meta.env.VITE_LSP;
|
||||
|
||||
return { network, proxy, esplora }
|
||||
return { network, proxy, esplora, rgs, lsp }
|
||||
}
|
||||
|
||||
export async function setAndGetMutinySettings(settings?: NodeManagerSettingStrings): Promise<NodeManagerSettingStrings> {
|
||||
let { network, proxy, esplora } = settings || {};
|
||||
let { network, proxy, esplora, rgs, lsp } = settings || {};
|
||||
|
||||
const existingSettings = getExistingSettings();
|
||||
try {
|
||||
network = network || existingSettings.network;
|
||||
proxy = proxy || existingSettings.proxy;
|
||||
esplora = esplora || existingSettings.esplora;
|
||||
rgs = rgs || existingSettings.rgs;
|
||||
lsp = lsp || existingSettings.lsp;
|
||||
|
||||
if (!network || !proxy || !esplora) {
|
||||
throw new Error("Missing a default setting for network, proxy, or esplora. Check your .env file to make sure it looks like .env.sample")
|
||||
}
|
||||
|
||||
localStorage.setItem('MUTINY_SETTINGS_network', network);
|
||||
localStorage.setItem('MUTINY_SETTINGS_proxy', proxy);
|
||||
localStorage.setItem('MUTINY_SETTINGS_esplora', esplora);
|
||||
|
||||
return { network, proxy, esplora }
|
||||
if (!rgs || !lsp) {
|
||||
console.warn("RGS or LSP not set")
|
||||
}
|
||||
|
||||
rgs && localStorage.setItem('MUTINY_SETTINGS_rgs', rgs);
|
||||
lsp && localStorage.setItem('MUTINY_SETTINGS_lsp', lsp);
|
||||
|
||||
return { network, proxy, esplora, rgs, lsp }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
@@ -52,18 +69,20 @@ export async function checkForWasm() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupNodeManager(): Promise<NodeManager> {
|
||||
export async function setupNodeManager(settings?: NodeManagerSettingStrings): Promise<NodeManager> {
|
||||
const _ = await init();
|
||||
|
||||
console.time("Setup");
|
||||
console.log("Starting setup...")
|
||||
const { network, proxy, esplora } = await setAndGetMutinySettings()
|
||||
const { network, proxy, esplora, rgs, lsp } = await setAndGetMutinySettings(settings)
|
||||
console.log("Initializing Node Manager")
|
||||
console.log("Using network", network);
|
||||
console.log("Using proxy", proxy);
|
||||
console.log("Using esplora address", esplora);
|
||||
console.log("Using rgs address", rgs);
|
||||
console.log("Using lsp address", lsp);
|
||||
|
||||
const nodeManager = await new NodeManager("", undefined, proxy, network, esplora)
|
||||
const nodeManager = await new NodeManager("", undefined, proxy, network, esplora, rgs, lsp)
|
||||
|
||||
const nodes = await nodeManager.list_nodes();
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply text-white bg-black;
|
||||
@apply bg-fixed bg-no-repeat bg-gradient-to-b from-black to-[#0b215b];
|
||||
@apply text-white bg-neutral-900;
|
||||
overscroll-behavior-y: none;
|
||||
min-height: 100.3%;
|
||||
}
|
||||
@@ -40,3 +39,8 @@ a {
|
||||
#video-container .scan-region-highlight-svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Missing you sveltekit */
|
||||
dd {
|
||||
@apply mb-8 mt-2;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "solid-start";
|
||||
import "./root.css";
|
||||
import { Provider as MegaStoreProvider } from "~/state/megaStore";
|
||||
import { Toaster } from "~/components/Toaster";
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
@@ -39,6 +40,7 @@ export default function Root() {
|
||||
<Routes>
|
||||
<FileRoutes />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</MegaStoreProvider>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
|
||||
25
src/routes/Admin.tsx
Normal file
25
src/routes/Admin.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DeleteEverything } from "~/components/DeleteEverything";
|
||||
import KitchenSink from "~/components/KitchenSink";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { Card, DefaultMain, LargeHeader, NodeManagerGuard, SafeArea, SmallHeader, VStack } from "~/components/layout";
|
||||
|
||||
export default function Admin() {
|
||||
return (
|
||||
<NodeManagerGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Admin</LargeHeader>
|
||||
<VStack>
|
||||
<Card><p>If you know what you're doing you're in the right place!</p></Card>
|
||||
<KitchenSink />
|
||||
<div class='rounded-xl p-4 flex flex-col gap-2 bg-m-red overflow-x-hidden'>
|
||||
<SmallHeader>Danger zone</SmallHeader>
|
||||
<DeleteEverything />
|
||||
</div>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea>
|
||||
</NodeManagerGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,43 @@
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { createMemo, createResource, createSignal, Match, Show, Suspense, Switch } from "solid-js";
|
||||
import { MutinyBip21RawMaterials, MutinyInvoice } from "@mutinywallet/mutiny-wasm";
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, onCleanup, Switch } from "solid-js";
|
||||
import { QRCodeSVG } from "solid-qr-code";
|
||||
import { AmountInput } from "~/components/AmountInput";
|
||||
import { Button, Card, SafeArea, SmallHeader } from "~/components/layout";
|
||||
import { AmountEditable } from "~/components/AmountEditable";
|
||||
import { Button, Card, DefaultMain, LargeHeader, NodeManagerGuard, SafeArea, SmallHeader } from "~/components/layout";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { satsToUsd } from "~/utils/conversions";
|
||||
import { objectToSearchParams } from "~/utils/objectToSearchParams";
|
||||
import { useCopy } from "~/utils/useCopy";
|
||||
import mempoolTxUrl from "~/utils/mempoolTxUrl";
|
||||
import { ReceiveSuccessModal } from "~/components/ReceiveSuccessModal";
|
||||
|
||||
import party from '~/assets/party.gif';
|
||||
import { Amount } from "~/components/Amount";
|
||||
|
||||
type OnChainTx = {
|
||||
transaction: {
|
||||
version: number
|
||||
lock_time: number
|
||||
input: Array<{
|
||||
previous_output: string
|
||||
script_sig: string
|
||||
sequence: number
|
||||
witness: Array<string>
|
||||
}>
|
||||
output: Array<{
|
||||
value: number
|
||||
script_pubkey: string
|
||||
}>
|
||||
}
|
||||
txid: string
|
||||
received: number
|
||||
sent: number
|
||||
confirmation_time: {
|
||||
height: number
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
function ShareButton(props: { receiveString: string }) {
|
||||
async function share(receiveString: string) {
|
||||
@@ -31,7 +61,8 @@ function ShareButton(props: { receiveString: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
type ReceiveState = "edit" | "show"
|
||||
type ReceiveState = "edit" | "show" | "paid"
|
||||
type PaidState = "lightning_paid" | "onchain_paid";
|
||||
|
||||
export default function Receive() {
|
||||
const [state, _] = useMegaStore()
|
||||
@@ -41,6 +72,24 @@ export default function Receive() {
|
||||
|
||||
const [receiveState, setReceiveState] = createSignal<ReceiveState>("edit")
|
||||
|
||||
const [bip21Raw, setBip21Raw] = createSignal<MutinyBip21RawMaterials>();
|
||||
|
||||
const [unified, setUnified] = createSignal("")
|
||||
|
||||
// The data we get after a payment
|
||||
const [paymentTx, setPaymentTx] = createSignal<OnChainTx>();
|
||||
const [paymentInvoice, setPaymentInvoice] = createSignal<MutinyInvoice>();
|
||||
|
||||
function clearAll() {
|
||||
setAmount("")
|
||||
setLabel("")
|
||||
setReceiveState("edit")
|
||||
setBip21Raw(undefined)
|
||||
setUnified("")
|
||||
setPaymentTx(undefined)
|
||||
setPaymentInvoice(undefined)
|
||||
}
|
||||
|
||||
let amountInput!: HTMLInputElement;
|
||||
let labelInput!: HTMLInputElement;
|
||||
|
||||
@@ -56,21 +105,24 @@ export default function Receive() {
|
||||
labelInput.focus();
|
||||
}
|
||||
|
||||
const [unified, setUnified] = createSignal("")
|
||||
|
||||
|
||||
const [copy, copied] = useCopy({ copiedTimeout: 1000 });
|
||||
|
||||
async function getUnifiedQr(amount: string, label: string) {
|
||||
const bigAmount = BigInt(amount);
|
||||
const bip21Raw = await state.node_manager?.create_bip21(bigAmount, label);
|
||||
const raw = await state.node_manager?.create_bip21(bigAmount, label);
|
||||
|
||||
// Save the raw info so we can watch the address and invoice
|
||||
setBip21Raw(raw);
|
||||
|
||||
const params = objectToSearchParams({
|
||||
amount: bip21Raw?.btc_amount,
|
||||
label: bip21Raw?.description,
|
||||
lightning: bip21Raw?.invoice
|
||||
amount: raw?.btc_amount,
|
||||
label: raw?.description,
|
||||
lightning: raw?.invoice
|
||||
})
|
||||
|
||||
return `bitcoin:${bip21Raw?.address}?${params}`
|
||||
return `bitcoin:${raw?.address}?${params}`
|
||||
}
|
||||
|
||||
async function onSubmit(e: Event) {
|
||||
@@ -82,68 +134,120 @@ export default function Receive() {
|
||||
setReceiveState("show")
|
||||
}
|
||||
|
||||
async function getPrice() {
|
||||
return await state.node_manager?.get_bitcoin_price()
|
||||
const amountInUsd = createMemo(() => satsToUsd(state.price, parseInt(amount()) || 0, true))
|
||||
|
||||
function handleAmountSave() {
|
||||
console.error("focusing label input...")
|
||||
console.error(labelInput)
|
||||
labelInput.focus();
|
||||
}
|
||||
|
||||
const [price] = createResource(getPrice)
|
||||
async function checkIfPaid(bip21?: MutinyBip21RawMaterials): Promise<PaidState | undefined> {
|
||||
if (bip21) {
|
||||
console.log("checking if paid...")
|
||||
const lightning = bip21.invoice
|
||||
const address = bip21.address
|
||||
|
||||
const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(amount()) || 0, true))
|
||||
const invoice = await state.node_manager?.get_invoice(lightning)
|
||||
|
||||
if (invoice && invoice.paid) {
|
||||
setReceiveState("paid")
|
||||
setPaymentInvoice(invoice)
|
||||
return "lightning_paid"
|
||||
}
|
||||
|
||||
const tx = await state.node_manager?.check_address(address) as OnChainTx | undefined;
|
||||
|
||||
if (tx) {
|
||||
setReceiveState("paid")
|
||||
setPaymentTx(tx)
|
||||
return "onchain_paid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [paidState, { refetch }] = createResource(bip21Raw, checkIfPaid);
|
||||
|
||||
createEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (receiveState() === "show") refetch();
|
||||
}, 1000); // Poll every second
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeArea main>
|
||||
<div class="w-full max-w-[400px] flex flex-col gap-4">
|
||||
<Suspense fallback={"..."}>
|
||||
{/* If I don't have this guard then the node manager only half-works */}
|
||||
<Show when={state.node_manager}>
|
||||
<Switch>
|
||||
<Match when={!unified() || receiveState() === "edit"}>
|
||||
<form class="border border-white/20 rounded-xl p-2 flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
{/* TODO this initial amount is not reactive, hope that's okay? */}
|
||||
<AmountInput initialAmountSats={amount()} setAmountSats={setAmount} refSetter={el => amountInput = el} />
|
||||
<TextField.Root
|
||||
value={label()}
|
||||
onValueChange={setLabel}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label class="text-sm font-semibold uppercase" >Label (private)</TextField.Label>
|
||||
<TextField.Input
|
||||
autofocus
|
||||
ref={el => labelInput = el}
|
||||
class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<Button disabled={!amount() || !label()} layout="small" type="submit">Create Invoice</Button>
|
||||
</form >
|
||||
</Match>
|
||||
<Match when={unified() && receiveState() === "show"}>
|
||||
<div class="w-full bg-white rounded-xl">
|
||||
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
|
||||
<NodeManagerGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Receive Bitcoin</LargeHeader>
|
||||
<Switch>
|
||||
<Match when={!unified() || receiveState() === "edit"}>
|
||||
<AmountEditable initialAmountSats={amount() || "0"} setAmountSats={setAmount} onSave={handleAmountSave} />
|
||||
<div>
|
||||
<Button intent="glowy" layout="xs">Tag the sender</Button>
|
||||
</div>
|
||||
<form class="flex flex-col gap-4" onSubmit={onSubmit} >
|
||||
<TextField.Root
|
||||
value={label()}
|
||||
onValueChange={setLabel}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TextField.Label><SmallHeader>Label (private)</SmallHeader></TextField.Label>
|
||||
<TextField.Input
|
||||
ref={el => labelInput = el}
|
||||
class="w-full p-2 rounded-lg text-black" />
|
||||
</TextField.Root>
|
||||
<Button disabled={!amount() || !label()} intent="green" type="submit">Create Invoice</Button>
|
||||
</form >
|
||||
</Match>
|
||||
<Match when={unified() && receiveState() === "show"}>
|
||||
<div class="w-full bg-white rounded-xl">
|
||||
<QRCodeSVG value={unified() ?? ""} class="w-full h-full p-8 max-h-[400px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 w-full">
|
||||
<Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
|
||||
<ShareButton receiveString={unified() ?? ""} />
|
||||
</div>
|
||||
<Card>
|
||||
<SmallHeader>Amount</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{amount()} sats</p><button onClick={editAmount}>✏️</button>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full">
|
||||
<Button onClick={(_) => copy(unified() ?? "")}>{copied() ? "Copied" : "Copy"}</Button>
|
||||
<ShareButton receiveString={unified() ?? ""} />
|
||||
<pre>({amountInUsd()})</pre>
|
||||
<SmallHeader>Private Label</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{label()} </p><button onClick={editLabel}>✏️</button>
|
||||
</div>
|
||||
<Card>
|
||||
<SmallHeader>Amount</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{amount()} sats</p><button onClick={editAmount}>✏️</button>
|
||||
</div>
|
||||
<pre>({amountInUsd()})</pre>
|
||||
<SmallHeader>Private Label</SmallHeader>
|
||||
<div class="flex justify-between">
|
||||
<p>{label()} </p><button onClick={editLabel}>✏️</button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Bip21">
|
||||
<code class="break-all">{unified()}</code>
|
||||
</Card>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea >
|
||||
|
||||
</Card>
|
||||
<Card title="Bip21">
|
||||
<code class="break-all">{unified()}</code>
|
||||
</Card>
|
||||
</Match>
|
||||
<Match when={receiveState() === "paid" && paidState() === "lightning_paid"}>
|
||||
<ReceiveSuccessModal title="Payment Received!" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
|
||||
<Amount amountSats={paymentInvoice()?.amount_sats} showFiat />
|
||||
</div>
|
||||
</ReceiveSuccessModal>
|
||||
</Match>
|
||||
<Match when={receiveState() === "paid" && paidState() === "onchain_paid"}>
|
||||
<ReceiveSuccessModal title="Payment Received!" open={!!paidState()} setOpen={(open: boolean) => { if (!open) clearAll() }}>
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<img src={party} alt="party" class="w-1/2 mx-auto max-w-[50vh] aspect-square" />
|
||||
<Amount amountSats={paymentTx()?.received} showFiat />
|
||||
<a href={mempoolTxUrl(paymentTx()?.txid, "signet")} target="_blank" rel="noreferrer">
|
||||
Mempool Link
|
||||
</a>
|
||||
</div>
|
||||
</ReceiveSuccessModal>
|
||||
</Match>
|
||||
</Switch>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="none" />
|
||||
</SafeArea >
|
||||
</NodeManagerGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,36 @@
|
||||
import Reader from "~/components/Reader";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { createEffect, createSignal, Show } from "solid-js";
|
||||
import { useNavigate } from "solid-start";
|
||||
import { Button } from "~/components/layout";
|
||||
|
||||
export default function Scanner() {
|
||||
const [scanResult, setScanResult] = createSignal<string | null>(null);
|
||||
const [scanResult, setScanResult] = createSignal<string>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function onResult(result: string) {
|
||||
setScanResult(result);
|
||||
}
|
||||
|
||||
// TODO: is this correct? we always go back to where we came from when we scan... kind of like scan is a modal tbh
|
||||
function exit() {
|
||||
navigate("/", { replace: true })
|
||||
history.back();
|
||||
}
|
||||
|
||||
function handlePaste() {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
setScanResult(text);
|
||||
});
|
||||
}
|
||||
|
||||
// When we have a nice result we can head over to the send screen
|
||||
createEffect(() => {
|
||||
if (scanResult()) {
|
||||
navigate("/send", { state: { destination: scanResult() } })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="safe-top safe-left safe-right safe-bottom h-screen-safe">
|
||||
<Show when={scanResult()} fallback={<Reader onResult={onResult} />}>
|
||||
<div class="w-full p-8">
|
||||
<div class="mt-[20vw] rounded-xl p-4 flex flex-col gap-2 bg-[rgba(0,0,0,0.5)]">
|
||||
@@ -31,14 +45,14 @@ export default function Scanner() {
|
||||
<Show when={scanResult()}
|
||||
fallback={
|
||||
<div class="w-full max-w-[800px] flex flex-col gap-2">
|
||||
<Button intent="blue" onClick={exit}>Paste Something</Button>
|
||||
<Button intent="blue" onClick={handlePaste}>Paste Something</Button>
|
||||
<Button onClick={exit}>Cancel</Button>
|
||||
</div>
|
||||
}>
|
||||
<Button intent="red" onClick={() => setScanResult(null)}>Try Again</Button>
|
||||
<Button intent="red" onClick={() => setScanResult(undefined)}>Try Again</Button>
|
||||
<Button onClick={exit}>Cancel</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
src/routes/Send.tsx
Normal file
252
src/routes/Send.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { TextField } from "@kobalte/core";
|
||||
import { Show, createMemo, createResource, createSignal, onMount } from "solid-js";
|
||||
import { Amount } from "~/components/Amount";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { Button, ButtonLink, DefaultMain, LargeHeader, SafeArea, SmallHeader } from "~/components/layout";
|
||||
import { Paste } from "~/assets/svg/Paste";
|
||||
import { Scan } from "~/assets/svg/Scan";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { MutinyInvoice, NodeManager } from "@mutinywallet/mutiny-wasm";
|
||||
import { bip21decode } from "~/utils/TEMPbip21";
|
||||
import { AmountEditable } from "~/components/AmountEditable";
|
||||
import { useLocation } from "solid-start";
|
||||
import { StyledRadioGroup } from "~/components/layout/Radio";
|
||||
import { SentModal } from "~/components/Sent";
|
||||
|
||||
type SendSource = "lightning" | "onchain";
|
||||
|
||||
const PAYMENT_METHODS = [{ value: "lightning", label: "Lightning", caption: "Fast and cool" }, { value: "onchain", label: "On-chain", caption: "Just like Satoshi did it" }]
|
||||
|
||||
// const TEST_DEST = "bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl"
|
||||
// const TEST_DEST_ADDRESS = "tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe"
|
||||
|
||||
type SentDetails = { nice: string }
|
||||
|
||||
export default function Send() {
|
||||
const [state, actions] = useMegaStore();
|
||||
|
||||
// These can only be set by the user
|
||||
const [destination, setDestination] = createSignal("");
|
||||
const [privateLabel, setPrivateLabel] = createSignal("");
|
||||
|
||||
// These can be derived from the "destination" signal or set by the user
|
||||
const [amountSats, setAmountSats] = createSignal(0n);
|
||||
const [source, setSource] = createSignal<SendSource>("lightning");
|
||||
|
||||
// These can only be derived from the "destination" signal
|
||||
const [invoice, setInvoice] = createSignal<MutinyInvoice>();
|
||||
const [address, setAddress] = createSignal<string>();
|
||||
const [description, setDescription] = createSignal<string>();
|
||||
|
||||
// Is sending / sent
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [sentDetails, setSentDetails] = createSignal<SentDetails>();
|
||||
|
||||
function clearAll() {
|
||||
setDestination("");
|
||||
setPrivateLabel("");
|
||||
setAmountSats(0n);
|
||||
setSource("lightning");
|
||||
setInvoice(undefined);
|
||||
setAddress(undefined);
|
||||
setDescription(undefined);
|
||||
}
|
||||
|
||||
// If we were routed to by the scanner we can get the state from there
|
||||
const location = useLocation();
|
||||
|
||||
const fakeFee = createMemo(() => {
|
||||
if (source() === "lightning") return 69n;
|
||||
if (source() === "onchain") return 420n;
|
||||
return 0n;
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// TODO: probably a cleaner way to make typescript happy
|
||||
const routerInfo = location as { state?: { destination?: string } };
|
||||
if (routerInfo.state?.destination && typeof routerInfo.state.destination === "string") {
|
||||
setDestination(routerInfo.state.destination);
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: this is pretty temp until we have WAILA
|
||||
async function decode(source: string) {
|
||||
if (!source) return;
|
||||
try {
|
||||
const { address, label, lightning, amount } = bip21decode(source);
|
||||
|
||||
setAddress(address)
|
||||
|
||||
if (lightning) {
|
||||
const invoice = await state.node_manager?.decode_invoice(lightning);
|
||||
if (invoice?.amount_sats) setAmountSats(invoice.amount_sats);
|
||||
setInvoice(invoice)
|
||||
// We can stick with default lightning because there's an invoice
|
||||
setSource("lightning")
|
||||
} else {
|
||||
// If we can't use the lightning amount we have to use the float btc amount
|
||||
const amt = NodeManager.convert_btc_to_sats(amount || 0);
|
||||
setAmountSats(amt);
|
||||
|
||||
// We use onchain because there's no invoice
|
||||
setSource("onchain")
|
||||
}
|
||||
|
||||
if (label) setDescription(label);
|
||||
|
||||
setInvoice(invoice)
|
||||
|
||||
return invoice
|
||||
|
||||
} catch (e) {
|
||||
console.error("error", e)
|
||||
clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: pass the signal but don't "call" the signal (`destination`, not `destination()`)
|
||||
const [decodedDestination] = createResource(destination, decode);
|
||||
|
||||
let labelInput!: HTMLInputElement;
|
||||
|
||||
function handlePaste() {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
setDestination(text);
|
||||
labelInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
try {
|
||||
setSending(true);
|
||||
const bolt11 = invoice()?.bolt11;
|
||||
if (source() === "lightning" && invoice() && bolt11) {
|
||||
const nodes = await state.node_manager?.list_nodes();
|
||||
const firstNode = nodes[0] as string || ""
|
||||
// If the invoice has sats use that, otherwise we pass the user-defined amount
|
||||
if (invoice()?.amount_sats) {
|
||||
await state.node_manager?.pay_invoice(firstNode, bolt11);
|
||||
} else {
|
||||
await state.node_manager?.pay_invoice(firstNode, bolt11, amountSats());
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const txid = await state.node_manager?.send_to_address(address()!, amountSats());
|
||||
// TODO: figure out if this is necessary, it takes forever
|
||||
await actions.sync();
|
||||
console.error(txid)
|
||||
}
|
||||
|
||||
setSentDetails({ nice: "nice" });
|
||||
clearAll();
|
||||
console.error("SENT");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Send Bitcoin</LargeHeader>
|
||||
<SentModal details={sentDetails()} />
|
||||
<dl>
|
||||
<dt>
|
||||
<SmallHeader>Destination</SmallHeader>
|
||||
</dt>
|
||||
<dd>
|
||||
<Show when={decodedDestination()} fallback={<div class="flex flex-row gap-4">
|
||||
<Button onClick={handlePaste}>
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Paste />
|
||||
<span>Paste</span>
|
||||
</div>
|
||||
</Button>
|
||||
<ButtonLink href="/scanner">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<Scan />
|
||||
<span>Scan QR</span>
|
||||
</div>
|
||||
</ButtonLink>
|
||||
</div>}>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Show when={address() && source() === "onchain"}>
|
||||
<code class="truncate text-sm break-all">{"Address: "} {address()}
|
||||
<Show when={description()}>
|
||||
<br />
|
||||
{"Description:"} {description()}
|
||||
</Show>
|
||||
</code>
|
||||
</Show>
|
||||
<Show when={invoice() && source() === "lightning"}>
|
||||
<code class="truncate text-sm break-all">{"Invoice: "} {invoice()?.bolt11}
|
||||
<Show when={description()}>
|
||||
<br />
|
||||
{"Description:"} {description()}
|
||||
</Show>
|
||||
</code>
|
||||
</Show>
|
||||
<Button class="flex-0" intent="glowy" layout="xs" onClick={clearAll}>Clear</Button>
|
||||
</div>
|
||||
<div class="my-8 flex gap-4 w-full items-center justify-around">
|
||||
{/* if the amount came with the invoice we can't allow setting it */}
|
||||
<Show when={!(invoice()?.amount_sats)} fallback={<Amount amountSats={amountSats() || 0} showFiat />}>
|
||||
<AmountEditable initialAmountSats={amountSats().toString() || "0"} setAmountSats={setAmountSats} />
|
||||
</Show>
|
||||
<div class="bg-white/10 px-4 py-2 rounded-xl">
|
||||
<div class="flex gap-2 items-center">
|
||||
<h2 class="text-neutral-400 font-semibold uppercase">+ Fee</h2>
|
||||
<h3 class="text-xl font-light text-neutral-300">
|
||||
{fakeFee().toLocaleString()} <span class='text-lg'>SATS</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<h2 class="font-semibold uppercase text-white">Total</h2>
|
||||
<h3 class="text-xl font-light text-white">
|
||||
{(amountSats().valueOf() + fakeFee().valueOf()).toLocaleString()} <span class='text-lg'>SATS</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</dd>
|
||||
<Show when={address() && invoice()}>
|
||||
<dt>
|
||||
<SmallHeader>
|
||||
Payment Method
|
||||
</SmallHeader>
|
||||
</dt>
|
||||
<dd>
|
||||
<StyledRadioGroup value={source()} onValueChange={setSource} choices={PAYMENT_METHODS} />
|
||||
</dd>
|
||||
</Show>
|
||||
<Show when={destination()}>
|
||||
<TextField.Root
|
||||
value={privateLabel()}
|
||||
onValueChange={setPrivateLabel}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<dt>
|
||||
<SmallHeader>
|
||||
<TextField.Label>Label (private)</TextField.Label>
|
||||
</SmallHeader>
|
||||
</dt>
|
||||
<dd>
|
||||
<TextField.Input
|
||||
autofocus
|
||||
ref={el => labelInput = el}
|
||||
class="w-full p-2 rounded-lg bg-white/10"
|
||||
placeholder="A helpful reminder of why you spent bitcoin"
|
||||
/>
|
||||
</dd>
|
||||
</TextField.Root>
|
||||
</Show>
|
||||
</dl>
|
||||
<Button disabled={!destination() || sending()} intent="blue" onClick={handleSend} loading={sending()}>{sending() ? "Sending..." : "Confirm Send"}</Button>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
</SafeArea >
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,28 @@
|
||||
import { useNavigate } from "solid-start";
|
||||
import { Button, SafeArea } from "~/components/layout";
|
||||
import { ButtonLink, DefaultMain, LargeHeader, NodeManagerGuard, SafeArea, VStack } from "~/components/layout";
|
||||
import NavBar from "~/components/NavBar";
|
||||
import { SeedWords } from "~/components/SeedWords";
|
||||
import { SettingsStringsEditor } from "~/components/SettingsStringsEditor";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [_, actions] = useMegaStore();
|
||||
|
||||
function clearWaitlistId() {
|
||||
actions.setWaitlistId('');
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
function setTestWaitlistId() {
|
||||
actions.setWaitlistId('npub17zcnktw7svnechf5g666t33d7slw36sz8el3ep4c7kmyfwjhxn9qjvavs6');
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
function resetNode() {
|
||||
Object.keys(localStorage).forEach(function (key) {
|
||||
if (key.startsWith('waitlist_id')) {
|
||||
// Don't do anything because it's annoying to set my waitlist_id every time
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
navigate("/")
|
||||
}
|
||||
const [store, _actions] = useMegaStore();
|
||||
|
||||
return (
|
||||
<SafeArea>
|
||||
<main class='flex flex-col gap-4 py-8 px-4 max-w-[800px] mx-auto'>
|
||||
<Button onClick={clearWaitlistId}>Clear waitlist_id</Button>
|
||||
<Button onClick={setTestWaitlistId}>Use test waitlist_id</Button>
|
||||
<Button onClick={resetNode}>Reset node</Button>
|
||||
</main>
|
||||
<NavBar activeTab="settings" />
|
||||
</SafeArea>
|
||||
<NodeManagerGuard>
|
||||
<SafeArea>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Settings</LargeHeader>
|
||||
<VStack biggap>
|
||||
<VStack>
|
||||
<p class="text-2xl font-light">Write down these words or you'll die!</p>
|
||||
<SeedWords words={store.node_manager?.show_seed() || ""} />
|
||||
</VStack>
|
||||
<SettingsStringsEditor />
|
||||
<ButtonLink href="/admin">"I know what I'm doing"</ButtonLink>
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="settings" />
|
||||
</SafeArea>
|
||||
</NodeManagerGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Title } from "solid-start";
|
||||
import { HttpStatusCode } from "solid-start/server";
|
||||
import { ButtonLink, DefaultMain, LargeHeader, SafeArea } from "~/components/layout";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main>
|
||||
<SafeArea>
|
||||
|
||||
<Title>Not Found</Title>
|
||||
<HttpStatusCode code={404} />
|
||||
<h1>Page Not Found</h1>
|
||||
<p>
|
||||
Visit{" "}
|
||||
<a href="https://start.solidjs.com" target="_blank">
|
||||
start.solidjs.com
|
||||
</a>{" "}
|
||||
to learn how to build SolidStart apps.
|
||||
</p>
|
||||
</main>
|
||||
<DefaultMain>
|
||||
<LargeHeader>Not Found</LargeHeader>
|
||||
<p>
|
||||
This is probably Paul's fault.
|
||||
</p>
|
||||
<div class="h-full" />
|
||||
<ButtonLink href="/" intent="red">Dangit</ButtonLink>
|
||||
</DefaultMain>
|
||||
</SafeArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,7 @@ import { Switch, Match } from "solid-js";
|
||||
import { WaitlistAlreadyIn } from "~/components/waitlist/WaitlistAlreadyIn";
|
||||
import WaitlistForm from "~/components/waitlist/WaitlistForm";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { LoadingSpinner } from "~/components/layout";
|
||||
|
||||
function FullscreenLoader() {
|
||||
return (
|
||||
<div class="w-screen h-screen flex justify-center items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { FullscreenLoader } from "~/components/layout";
|
||||
|
||||
export default function Home() {
|
||||
const [state, _] = useMegaStore();
|
||||
@@ -20,8 +12,8 @@ export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Switch fallback={<FullscreenLoader />} >
|
||||
{/* TODO: might need this state.node_manager guard on all wallet routes */}
|
||||
<Match when={state.user_status === "approved" && state.node_manager}>
|
||||
{/* TODO: can you put a suspense around a match? */}
|
||||
<Match when={state.user_status === "approved"}>
|
||||
<App />
|
||||
</Match>
|
||||
<Match when={state.user_status === "waitlisted"}>
|
||||
@@ -32,6 +24,5 @@ export default function Home() {
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
// Inspired by https://github.com/solidjs/solid-realworld/blob/main/src/store/index.js
|
||||
|
||||
import { ParentComponent, createContext, createEffect, useContext } from "solid-js";
|
||||
import { ParentComponent, createContext, createEffect, onCleanup, onMount, useContext } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { setupNodeManager } from "~/logic/nodeManagerSetup";
|
||||
import { NodeManager } from "@mutinywallet/node-manager";
|
||||
import { NodeManagerSettingStrings, setupNodeManager } from "~/logic/nodeManagerSetup";
|
||||
import { MutinyBalance, NodeManager } from "@mutinywallet/mutiny-wasm";
|
||||
|
||||
const MegaStoreContext = createContext<MegaStore>();
|
||||
|
||||
type UserStatus = undefined | "new_here" | "waitlisted" | "approved" | "paid"
|
||||
|
||||
export type MegaStore = [{
|
||||
waitlist_id: string | null;
|
||||
node_manager: NodeManager | undefined;
|
||||
waitlist_id?: string;
|
||||
node_manager?: NodeManager;
|
||||
user_status: UserStatus;
|
||||
scan_result?: string;
|
||||
balance?: MutinyBalance;
|
||||
last_sync?: number;
|
||||
price: number
|
||||
}, {
|
||||
status(): Promise<UserStatus>;
|
||||
setupNodeManager(): Promise<void>;
|
||||
fetchUserStatus(): Promise<UserStatus>;
|
||||
setupNodeManager(settings?: NodeManagerSettingStrings): Promise<void>;
|
||||
setWaitlistId(waitlist_id: string): void;
|
||||
sync(): Promise<void>;
|
||||
}];
|
||||
|
||||
export const Provider: ParentComponent = (props) => {
|
||||
const [state, setState] = createStore({
|
||||
|
||||
waitlist_id: localStorage.getItem("waitlist_id"),
|
||||
node_manager: undefined as NodeManager | undefined,
|
||||
user_status: undefined as UserStatus,
|
||||
// TODO: wire this up to real price once we have caching
|
||||
price: 30000
|
||||
});
|
||||
|
||||
const actions = {
|
||||
async status(): Promise<UserStatus> {
|
||||
async fetchUserStatus(): Promise<UserStatus> {
|
||||
if (!state.waitlist_id) {
|
||||
return "new_here"
|
||||
}
|
||||
@@ -42,16 +48,13 @@ export const Provider: ParentComponent = (props) => {
|
||||
return "waitlisted"
|
||||
}
|
||||
|
||||
// TODO: handle paid status
|
||||
|
||||
} catch (e) {
|
||||
return "new_here"
|
||||
}
|
||||
|
||||
},
|
||||
async setupNodeManager(): Promise<void> {
|
||||
async setupNodeManager(settings?: NodeManagerSettingStrings): Promise<void> {
|
||||
try {
|
||||
const nodeManager = await setupNodeManager()
|
||||
const nodeManager = await setupNodeManager(settings)
|
||||
setState({ node_manager: nodeManager })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -59,17 +62,28 @@ export const Provider: ParentComponent = (props) => {
|
||||
},
|
||||
setWaitlistId(waitlist_id: string) {
|
||||
setState({ waitlist_id })
|
||||
},
|
||||
async sync(): Promise<void> {
|
||||
console.time("BDK Sync Time")
|
||||
console.groupCollapsed("BDK Sync")
|
||||
try {
|
||||
await state.node_manager?.sync()
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
console.groupEnd();
|
||||
console.timeEnd("BDK Sync Time")
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch status from remote on load
|
||||
createEffect(() => {
|
||||
actions.status().then(status => {
|
||||
onMount(() => {
|
||||
actions.fetchUserStatus().then(status => {
|
||||
setState({ user_status: status })
|
||||
})
|
||||
})
|
||||
|
||||
// Only node manager when status is approved
|
||||
// Only load node manager when status is approved
|
||||
createEffect(() => {
|
||||
if (state.user_status === "approved" && !state.node_manager) {
|
||||
console.log("running setup node manager...")
|
||||
@@ -82,6 +96,16 @@ export const Provider: ParentComponent = (props) => {
|
||||
state.waitlist_id ? localStorage.setItem("waitlist_id", state.waitlist_id) : localStorage.removeItem("waitlist_id");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (state.node_manager) actions.sync();
|
||||
}, 60 * 1000); // Poll every minute
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
})
|
||||
|
||||
const store = [state, actions] as MegaStore;
|
||||
|
||||
return (
|
||||
|
||||
25
src/utils/TEMPbip21.ts
Normal file
25
src/utils/TEMPbip21.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Take in a string that looks like this:
|
||||
// bitcoin:tb1pdh43en28jmhnsrhxkusja46aufdlae5qnfrhucw5jvefw9flce3sdxfcwe?amount=0.00001&label=heyo&lightning=lntbs10u1pjrwrdedq8dpjhjmcnp4qd60w268ve0jencwzhz048ruprkxefhj0va2uspgj4q42azdg89uupp5gngy2pqte5q5uvnwcxwl2t8fsdlla5s6xl8aar4xcsvxeus2w2pqsp5n5jp3pz3vpu92p3uswttxmw79a5lc566herwh3f2amwz2sp6f9tq9qyysgqcqpcxqrpwugv5m534ww5ukcf6sdw2m75f2ntjfh3gzeqay649256yvtecgnhjyugf74zakaf56sdh66ec9fqep2kvu6xv09gcwkv36rrkm38ylqsgpw3yfjl
|
||||
// and return an object with this shape: { address: string, amount: number, label: string, lightning: string }
|
||||
// using typescript type annotations
|
||||
export function bip21decode(bip21: string): { address?: string, amount?: number, label?: string, lightning?: string } {
|
||||
const [scheme, data] = bip21.split(':')
|
||||
if (scheme !== 'bitcoin') {
|
||||
// TODO: this is a WAILA job I just want to debug more of the send flow
|
||||
if (bip21.startsWith('lnt')) {
|
||||
return { lightning: bip21 }
|
||||
} else if (bip21.startsWith('tb1')) {
|
||||
return { address: bip21 }
|
||||
} else {
|
||||
throw new Error('Not a bitcoin URI')
|
||||
}
|
||||
}
|
||||
const [address, query] = data.split('?')
|
||||
const params = new URLSearchParams(query)
|
||||
return {
|
||||
address,
|
||||
amount: Number(params.get('amount')) || undefined,
|
||||
label: params.get('label') || undefined,
|
||||
lightning: params.get('lightning') || undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NodeManager } from "@mutinywallet/node-manager";
|
||||
import { NodeManager } from "@mutinywallet/mutiny-wasm";
|
||||
|
||||
export function satsToUsd(amount: number | undefined, price: number, formatted: boolean): string {
|
||||
if (typeof amount !== "number" || isNaN(amount)) {
|
||||
|
||||
12
src/utils/prettyPrintTime.ts
Normal file
12
src/utils/prettyPrintTime.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function prettyPrintTime(ts: number) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
};
|
||||
|
||||
return new Date(ts * 1000).toLocaleString('en-US', options);
|
||||
}
|
||||
@@ -16,19 +16,36 @@ module.exports = {
|
||||
colors: {
|
||||
"half-black": "rgba(0, 0, 0, 0.5)",
|
||||
"faint-white": "rgba(255, 255, 255, 0.1)",
|
||||
"m-red": "#F61D5B",
|
||||
"light-text": "rgba(250, 245, 234, 0.5)",
|
||||
"m-green": "hsla(163, 70%, 38%, 1)",
|
||||
"m-green-dark": "hsla(163, 70%, 28%, 1)",
|
||||
"m-blue": "hsla(220, 59%, 52%, 1)",
|
||||
"m-blue-dark": "hsla(220, 59%, 42%, 1)",
|
||||
"m-red": "hsla(343, 92%, 54%, 1)",
|
||||
"m-red-dark": "hsla(343, 92%, 44%, 1)",
|
||||
"sidebar-gray": "hsla(222, 15%, 7%, 1)"
|
||||
},
|
||||
backgroundImage: {
|
||||
'fade-to-blue': 'linear-gradient(1.63deg, #0B215B 32.05%, rgba(11, 33, 91, 0) 84.78%)'
|
||||
'fade-to-blue': 'linear-gradient(1.63deg, #0B215B 32.05%, rgba(11, 33, 91, 0) 84.78%)',
|
||||
'subtle-fade': 'linear-gradient(180deg, #060A13 0%, #131E39 100%)',
|
||||
'richer-fade': 'linear-gradient(180deg, hsla(224, 20%, 8%, 1) 0%, hsla(224, 20%, 15%, 1) 100%)'
|
||||
},
|
||||
dropShadow: {
|
||||
'blue-glow': '0px 0px 32px rgba(11, 33, 91, 0.5)'
|
||||
}
|
||||
|
||||
'blue-glow': '0px 0px 32px rgba(11, 33, 91, 0.5)',
|
||||
},
|
||||
boxShadow: {
|
||||
'inner-button': '2px 2px 4px rgba(0, 0, 0, 0.1), inset 2px 2px 4px rgba(255, 255, 255, 0.1), inset -2px -2px 6px rgba(0, 0, 0, 0.2)',
|
||||
'fancy-card': '0px 4px 4px rgba(0, 0, 0, 0.1)',
|
||||
'above': '0px -4px 10px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
textShadow: {
|
||||
'button': '1px 1px 0px rgba(0, 0, 0, 0.4)'
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// default prefix is "ui"
|
||||
require("@kobalte/tailwindcss"),
|
||||
plugin(function ({ addUtilities }) {
|
||||
const newUtilities = {
|
||||
'.safe-top': {
|
||||
@@ -47,6 +64,15 @@ module.exports = {
|
||||
paddingBottom: 'constant(safe-area-inset-bottom)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
},
|
||||
'.h-screen-safe': {
|
||||
height: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))'
|
||||
},
|
||||
'.min-h-screen-safe': {
|
||||
minHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))'
|
||||
},
|
||||
'.max-h-screen-safe': {
|
||||
maxHeight: 'calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom)))'
|
||||
},
|
||||
'.disable-scrollbars': {
|
||||
scrollbarWidth: 'none',
|
||||
'-ms-overflow-style': 'none',
|
||||
@@ -66,8 +92,18 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addUtilities(newUtilities);
|
||||
})
|
||||
}),
|
||||
// Text shadow!
|
||||
plugin(function ({ matchUtilities, theme }) {
|
||||
matchUtilities(
|
||||
{
|
||||
'text-shadow': (value) => ({
|
||||
textShadow: value,
|
||||
}),
|
||||
},
|
||||
{ values: theme('textShadow') }
|
||||
)
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export default defineConfig({
|
||||
},
|
||||
optimizeDeps: {
|
||||
// This is necessary because otherwise `vite dev` can't find the wasm
|
||||
exclude: ["@mutinywallet/node-manager"],
|
||||
exclude: ["@mutinywallet/mutiny-wasm"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user