diff --git a/src/assets/icons/receive.svg b/src/assets/icons/receive.svg new file mode 100644 index 0000000..828b559 --- /dev/null +++ b/src/assets/icons/receive.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Activity.tsx b/src/components/Activity.tsx new file mode 100644 index 0000000..d308886 --- /dev/null +++ b/src/components/Activity.tsx @@ -0,0 +1,204 @@ +import send from '~/assets/icons/send.svg'; +import receive from '~/assets/icons/receive.svg'; +import { Card, Hr, LoadingSpinner, SmallAmount, SmallHeader, VStack } from './layout'; +import { For, JSX, Match, Show, 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 './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 +} + +function SubtleText(props: { children: any }) { + return

{props.children}

+} + +function OnChainItem(props: { item: OnChainTx }) { + const isReceive = createMemo(() => props.item.received > 0); + + const [open, setOpen] = createSignal(false) + + return ( + <> + + + Mempool Link + + + +
setOpen(!open())}> + {isReceive() ? receive arrow : send arrow} +
+

Label Missing

+ {isReceive() ? : } + {/*

Txid: {props.item.txid}

*/} +
+
+ + {isReceive() ? "RECEIVE" : "SEND"} + + {props.item.confirmation_time ? prettyPrintTime(props.item.confirmation_time.timestamp) : "Unconfirmed"} +
+
+ + ) +} + +function InvoiceItem(props: { item: MutinyInvoice }) { + const isSend = createMemo(() => props.item.is_send); + + const [open, setOpen] = createSignal(false) + + return ( + <> + +
setOpen(!open())}> + {isSend() ? send arrow : receive arrow} +
+

Label Missing

+ +
+
+ + {isSend() ? "SEND" : "RECEIVE"} + + {prettyPrintTime(Number(props.item.expire))} +
+
+ + ) +} + +function Utxo(props: { item: Utxo }) { + const spent = createMemo(() => props.item.is_spent); + + const [open, setOpen] = createSignal(false) + + return ( + <> + +
setOpen(!open())}> + receive arrow +
+

Label Missing

+ +
+
+ + {spent() ? "SPENT" : "UNSPENT"} + +
+
+ + ) +} + +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 ( + + + + + + + + + No transactions (empty state) + + = 0}> + + {(tx) => + + } + + + + + + + + + + + No invoices (empty state) + + = 0}> + + {(invoice) => + + } + + + + + + + + + + + No utxos (empty state) + + = 0}> + + {(utxo) => + + } + + + + + + + ) + +} \ No newline at end of file diff --git a/src/components/Amount.tsx b/src/components/Amount.tsx index 35be955..0705368 100644 --- a/src/components/Amount.tsx +++ b/src/components/Amount.tsx @@ -12,12 +12,7 @@ function prettyPrintAmount(n?: number | bigint): string { export function Amount(props: { amountSats: bigint | number | undefined, showFiat?: boolean, loading?: boolean }) { const [state, _] = useMegaStore() - async function getPrice() { - return await state.node_manager?.get_bitcoin_price() - } - - const [price] = createResource(getPrice) - const amountInUsd = () => satsToUsd(price(), Number(props.amountSats) || 0, true) + const amountInUsd = () => satsToUsd(state.price, Number(props.amountSats) || 0, true) return (
diff --git a/src/components/AmountEditable.tsx b/src/components/AmountEditable.tsx index 27acd98..9c3a3c2 100644 --- a/src/components/AmountEditable.tsx +++ b/src/components/AmountEditable.tsx @@ -98,12 +98,7 @@ export function AmountEditable(props: { initialAmountSats: string, setAmountSats // Fiat conversion const [state, _] = useMegaStore() - async function getPrice() { - return await state.node_manager?.get_bitcoin_price() - } - - const [price] = createResource(getPrice) - const amountInUsd = () => satsToUsd(price(), Number(displayAmount()) || 0, true) + const amountInUsd = () => satsToUsd(state.price, Number(displayAmount()) || 0, true) // What we're all here for in the first place: returning a value function handleSubmit() { diff --git a/src/components/App.tsx b/src/components/App.tsx index 3d43b73..da0cc5c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,6 +5,7 @@ import NavBar from "~/components/NavBar"; import ReloadPrompt from "~/components/Reload"; import { Scan } from '~/assets/svg/Scan'; import { A } from 'solid-start'; +import { Activity } from './Activity'; export default function App() { return ( @@ -15,8 +16,9 @@ export default function App() { logo - + + diff --git a/src/components/JsonModal.tsx b/src/components/JsonModal.tsx new file mode 100644 index 0000000..3e04e7a --- /dev/null +++ b/src/components/JsonModal.tsx @@ -0,0 +1,44 @@ +import { Dialog } from "@kobalte/core"; +import { JSX, createMemo } from "solid-js"; +import { Button, ButtonLink, 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 overflow-y-scroll disable-scrollbars" + +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 ( + props.setOpen(isOpen)}> + + +
+ +
+ + + {props.title} + + + + X + +
+ +
+                                {json()}
+                            
+ {props.children} + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/KitchenSink.tsx b/src/components/KitchenSink.tsx index 130eca4..bb464ae 100644 --- a/src/components/KitchenSink.tsx +++ b/src/components/KitchenSink.tsx @@ -28,7 +28,7 @@ function PeersList() { No peers}> {(peer) => ( -
+                        
                             {JSON.stringify(peer, null, 2)}
                         
)} @@ -102,7 +102,7 @@ function ChannelsList() { No channels}> {(channel) => ( <> -
+                            
                                 {JSON.stringify(channel, null, 2)}
                             
@@ -182,7 +182,7 @@ function OpenChannel(props: { refetchChannels: RefetchChannelsListType }) { -
+                
                     {JSON.stringify(newChannel()?.outpoint, null, 2)}
                 
{newChannel()?.outpoint}
diff --git a/src/components/Sent.tsx b/src/components/Sent.tsx index 64f5e7f..3cc3f89 100644 --- a/src/components/Sent.tsx +++ b/src/components/Sent.tsx @@ -6,7 +6,6 @@ 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 ( diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index e37b095..09ed47f 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -4,11 +4,13 @@ import { Button, ButtonLink } from "./Button" import { Separator } from "@kobalte/core" import { useMegaStore } from "~/state/megaStore" -const SmallHeader: ParentComponent = (props) =>
{props.children}
+const SmallHeader: ParentComponent<{ class?: string }> = (props) => { + return
{props.children}
+} const Card: ParentComponent<{ title?: string }> = (props) => { return ( -
+
{props.title && {props.title}} {props.children}
@@ -74,8 +76,8 @@ const NodeManagerGuard: ParentComponent = (props) => { ) } -const LoadingSpinner = () => { - return (
+const LoadingSpinner = (props: { big?: boolean }) => { + return (

{props.children}

) } -export { SmallHeader, Card, SafeArea, LoadingSpinner, Button, ButtonLink, Linkify, Hr, NodeManagerGuard, FullscreenLoader, InnerCard, FancyCard, DefaultMain, LargeHeader } +const VStack: ParentComponent = (props) => { + return (
{props.children}
) +} + +const SmallAmount: ParentComponent<{ amount: number | bigint }> = (props) => { + return (

{props.amount.toLocaleString()} SATS

) +} + +export { + SmallHeader, + Card, + SafeArea, + LoadingSpinner, + Button, + ButtonLink, + Linkify, + Hr, + NodeManagerGuard, + FullscreenLoader, + InnerCard, + FancyCard, + DefaultMain, + LargeHeader, + VStack, + SmallAmount +} diff --git a/src/routes/Admin.tsx b/src/routes/Admin.tsx new file mode 100644 index 0000000..e059580 --- /dev/null +++ b/src/routes/Admin.tsx @@ -0,0 +1,20 @@ +import { Activity } from "~/components/Activity"; +import KitchenSink from "~/components/KitchenSink"; +import NavBar from "~/components/NavBar"; +import { Card, DefaultMain, LargeHeader, SafeArea, VStack } from "~/components/layout"; + +export default function Admin() { + return ( + + + Admin + +

If you know what you're doing you're in the right place!

+ + +
+
+ +
+ ) +} \ No newline at end of file diff --git a/src/routes/Receive.tsx b/src/routes/Receive.tsx index 0d40ef7..ba18fb0 100644 --- a/src/routes/Receive.tsx +++ b/src/routes/Receive.tsx @@ -82,14 +82,7 @@ export default function Receive() { setReceiveState("show") } - async function getPrice() { - // return await state.node_manager?.get_bitcoin_price() - return 30000 - } - - const [price] = createResource(getPrice) - - const amountInUsd = createMemo(() => satsToUsd(price(), parseInt(amount()) || 0, true)) + const amountInUsd = createMemo(() => satsToUsd(state.price, parseInt(amount()) || 0, true)) function handleAmountSave() { console.error("focusing label input...") diff --git a/src/state/megaStore.tsx b/src/state/megaStore.tsx index f34ae65..3e3a963 100644 --- a/src/state/megaStore.tsx +++ b/src/state/megaStore.tsx @@ -16,6 +16,7 @@ export type MegaStore = [{ scan_result?: string; balance?: MutinyBalance; last_sync?: number; + price: number }, { fetchUserStatus(): Promise; setupNodeManager(): Promise; @@ -27,6 +28,8 @@ export const Provider: ParentComponent = (props) => { 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 = { diff --git a/src/utils/prettyPrintTime.ts b/src/utils/prettyPrintTime.ts new file mode 100644 index 0000000..3b827b5 --- /dev/null +++ b/src/utils/prettyPrintTime.ts @@ -0,0 +1,12 @@ +export function prettyPrintTime(ts: number) { + const options = { + weekday: 'long', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric' + }; + + return new Date(ts * 1000).toLocaleString('en-US', options as any); +} \ No newline at end of file diff --git a/tailwind.config.cjs b/tailwind.config.cjs index e838c79..9cd610e 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -70,6 +70,9 @@ module.exports = { '.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',