diff options
| author | Adam Soltys <[email protected]> | 2025-02-07 22:23:00 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2025-02-07 22:23:00 -0800 |
| commit | 479cb9ad44d05b56cd2e60735687702fc37487a0 (patch) | |
| tree | fad9e4080887c2f11e8f750714c83e619f833afa | |
| parent | d92352dafc6d9e62716625d75be2316039a97a69 (diff) | |
| download | coinos-ui-479cb9ad44d05b56cd2e60735687702fc37487a0.tar.gz coinos-ui-479cb9ad44d05b56cd2e60735687702fc37487a0.zip | |
nwc connections
| -rw-r--r-- | src/components/AppForm.svelte | 163 | ||||
| -rw-r--r-- | src/locales/en.json | 24 | ||||
| -rw-r--r-- | src/routes/(app)/apps/[pubkey]/+page.server.ts | 26 | ||||
| -rw-r--r-- | src/routes/(app)/apps/[pubkey]/+page.svelte | 19 | ||||
| -rw-r--r-- | src/routes/(app)/apps/new/+page.server.ts | 31 | ||||
| -rw-r--r-- | src/routes/(app)/apps/new/+page.svelte | 19 | ||||
| -rw-r--r-- | src/routes/(app)/qr/[text]/+page.server.ts | 3 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.server.ts | 7 | ||||
| -rw-r--r-- | src/routes/(app)/settings/nostr/+page.svelte | 151 | ||||
| -rw-r--r-- | static/images/alby.svg | 65 |
10 files changed, 469 insertions, 39 deletions
diff --git a/src/components/AppForm.svelte b/src/components/AppForm.svelte new file mode 100644 index 00000000..ffdd3d26 --- /dev/null +++ b/src/components/AppForm.svelte @@ -0,0 +1,163 @@ +<script> + import Numpad from "$comp/Numpad.svelte"; + import { getPublicKey } from "nostr-tools"; + import { onMount, tick } from "svelte"; + import { bytesToHex, randomBytes } from "@noble/hashes/utils"; + import { goto } from "$app/navigation"; + import { copy, focus, fail, post } from "$lib/utils"; + import { t } from "$lib/translations"; + import { enhance } from "$app/forms"; + let { rate, user, name, max_amount, budget_renewal, pubkey, secret } = + $props(); + let { currency } = $derived(user); + + onMount(() => { + if (!pubkey) secret || generate(); + if (!budget_renewal) budget_renewal = "never"; + }); + + let generate = () => { + secret = bytesToHex(randomBytes(32)); + pubkey = getPublicKey(secret); + }; + + let del = async () => { + try { + await post(`/post/apps/delete`, { pubkey }); + goto("/settings/nostr"); + } catch (e) { + fail(e.message); + } + }; + + let settingBudget = $state(); + let setBudget = () => { + settingBudget = false; + }; + let done = $state(); + let startSetting = async () => { + settingBudget = true; + await tick(); + numpad.focus(); + }; + let numpad = $state(); +</script> + +<form use:enhance method="POST" class="space-y-5"> + <div> + <label for="name" class="font-bold mb-1 block">{$t("accounts.name")}</label> + <input type="text" name="name" bind:value={name} use:focus /> + </div> + + <div> + <label for="name" class="font-bold mb-1 block" + >{$t("accounts.budget")}</label + > + + <button type="button" class="flex w-full" onclick={startSetting}> + <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + </div> + <div + class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left" + > + {max_amount || $t("accounts.none")} + </div> + <input type="hidden" name="max_amount" bind:value={max_amount} /> + </button> + </div> + + <div> + <label for="name" class="font-bold mb-1 block" + >{$t("accounts.renewal")}</label + > + <select name="budget_renewal" bind:value={budget_renewal}> + <option value="never" selected>{$t("accounts.never")}</option> + <option value="daily">{$t("accounts.daily")}</option> + <option value="weekly">{$t("accounts.weekly")}</option> + <option value="monthly">{$t("accounts.monthly")}</option> + <option value="yearly">{$t("accounts.yearly")}</option> + </select> + </div> + + <div> + <label for="pubkey" class="font-bold mb-1 block" + >{$t("accounts.pubkey")}</label + > + <div class="flex gap-1 items-center"> + <textarea + rows={3} + name="pubkey" + bind:value={pubkey} + class="grow" + readonly={secret} + /> + <button + type="button" + onclick={() => copy(pubkey)} + class="btn !w-auto shrink" + > + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + </div> + </div> + + {#if !pubkey || secret} + <div> + <label for="secret" class="font-bold mb-1 block" + >{$t("accounts.secret")}</label + > + <div class="flex gap-1 items-center"> + <textarea rows={3} name="secret" bind:value={secret} lcass="grow" /> + + <div class="space-y-2 w-24"> + <button type="button" onclick={generate} class="btn"> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32" + ></iconify-icon> + </button> + <button type="button" onclick={() => copy(secret)} class="btn"> + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + </div> + </div> + </div> + {/if} + + <div class="space-y-2"> + <button type="submit" class="btn btn-accent">{$t("payments.submit")}</button + > + {#if pubkey} + <button type="button" class="btn" onclick={del}> + <iconify-icon icon="ph:trash-bold" width="32"></iconify-icon> + {$t("accounts.delete")}</button + > + {/if} + </div> +</form> + +{#if settingBudget} + <div + class="fixed bg-base-100 bg-opacity-90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto" + > + <div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5"> + <h1 class="text-center text-2xl font-semibold"> + {$t("accounts.budget")} + </h1> + <Numpad + bind:amount={max_amount} + {currency} + {rate} + bind:submit={done} + bind:element={numpad} + /> + + <button bind:this={done} type="button" onclick={setBudget} class="btn" + >Ok</button + > + </div> + </div> +{/if} diff --git a/src/locales/en.json b/src/locales/en.json index 27736963..1725abf0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4,6 +4,25 @@ "notFound": "No notes found" }, "accounts": { + "per": "per", + "none": "None", + "day": "day", + "week": "week", + "month": "month", + "year": "year", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly", + "never": "Never", + "newConection": "New connection", + "name": "Name", + "pubkey": "Pubkey", + "secret": "Secret", + "connection": "Connection", + "budget": "Budget", + "delete": "Delete", + "renewal": "Renewal period", "showQR": "Show QR", "revealNwc": "Reveal connection string", "finish": "Finish", @@ -206,7 +225,7 @@ "moreDetails": "Profile details", "noMessages": "You have not sent or received any messages", "profilePage": "Profile page", - "nwc": "Nostr wallet connect", + "nwc": "Nostr wallet connections", "nostrPubkey": "Nostr pubkey", "requestPayment": "Request Payment", "welcome": "Welcome! Your account balance is empty. You can start by making a deposit or by asking someone to pay you.", @@ -257,6 +276,7 @@ "placeholder": "Enter an address, invoice or username" }, "settings": { + "name": "Name", "integrations": "Integrations", "syncWithExtension": "Sync with extension", "connectNostr": "Nostr Wallet Connect", @@ -285,7 +305,7 @@ "nostrKeys": "Nostr secret key", "nostrDescription": "Your nostr secret key is used to sign and decrypt messages, keep it safe or someone may be able to impersonate you.", "nwc": "Nostr wallet connect", - "nwcDescription": "Enable your nostr app to send payments from your account", + "nwcDescription": "Enable nostr apps to send payments from your account", "seedDescription": "This 12 word seed phrase is used to derive your nostr private key and your self-custodial wallet keys which determine your auto-withdrawal addresses. Back it up and don't let anyone see it.", "emailDescription": "For password resets and payment notifications only", "hideNsec": "Hide nostr secret key", diff --git a/src/routes/(app)/apps/[pubkey]/+page.server.ts b/src/routes/(app)/apps/[pubkey]/+page.server.ts new file mode 100644 index 00000000..a2cc50e7 --- /dev/null +++ b/src/routes/(app)/apps/[pubkey]/+page.server.ts @@ -0,0 +1,26 @@ +import getRates from "$lib/rates"; +import { auth, fd, get, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ cookies, params, parent }) => { + const { pubkey } = params; + const { user } = await parent(); + const rates = await getRates(); + const rate = rates[user.currency]; + const app = await get(`/app/${pubkey}`, auth(cookies)); + return { app, rate }; +}; + +export const actions = { + default: async ({ cookies, request }) => { + try { + const body = await fd(request); + await post("/app", body, auth(cookies)); + } catch (e) { + const { message: error } = e as Error; + return fail(400, { error }); + } + + redirect(307, "/settings/nostr"); + }, +}; diff --git a/src/routes/(app)/apps/[pubkey]/+page.svelte b/src/routes/(app)/apps/[pubkey]/+page.svelte new file mode 100644 index 00000000..d1a5f2bd --- /dev/null +++ b/src/routes/(app)/apps/[pubkey]/+page.svelte @@ -0,0 +1,19 @@ +<script> + import { t } from "$lib/translations"; + import AppForm from "$comp/AppForm.svelte"; + import { untrack } from "svelte"; + let { data, form } = $props(); + let { app, rate, user } = $derived(data); +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> + <h2 class="text-2xl font-bold mb-2">{$t("accounts.connection")}</h2> + + {#if form?.error} + <div class="text-red-600 text-center"> + {form.error} + </div> + {/if} + + <AppForm {...app} {rate} {user} /> +</div> diff --git a/src/routes/(app)/apps/new/+page.server.ts b/src/routes/(app)/apps/new/+page.server.ts new file mode 100644 index 00000000..7c335f8c --- /dev/null +++ b/src/routes/(app)/apps/new/+page.server.ts @@ -0,0 +1,31 @@ +import getRates from "$lib/rates"; +import { auth, fd, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ parent, url }) => { + const app = { + name: url.searchParams.get("name"), + pubkey: url.searchParams.get("pubkey"), + max_amount: url.searchParams.get("max_amount"), + budget_renewal: url.searchParams.get("budget_renewal"), + }; + + const { user } = await parent(); + const rates = await getRates(); + const rate = rates[user.currency]; + return { app, rate }; +}; + +export const actions = { + default: async ({ cookies, request }) => { + try { + const body = await fd(request); + await post("/app", body, auth(cookies)); + } catch (e) { + const { message: error } = e as Error; + return fail(400, { error }); + } + + redirect(307, "/settings/nostr"); + }, +}; diff --git a/src/routes/(app)/apps/new/+page.svelte b/src/routes/(app)/apps/new/+page.svelte new file mode 100644 index 00000000..d1a5f2bd --- /dev/null +++ b/src/routes/(app)/apps/new/+page.svelte @@ -0,0 +1,19 @@ +<script> + import { t } from "$lib/translations"; + import AppForm from "$comp/AppForm.svelte"; + import { untrack } from "svelte"; + let { data, form } = $props(); + let { app, rate, user } = $derived(data); +</script> + +<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> + <h2 class="text-2xl font-bold mb-2">{$t("accounts.connection")}</h2> + + {#if form?.error} + <div class="text-red-600 text-center"> + {form.error} + </div> + {/if} + + <AppForm {...app} {rate} {user} /> +</div> diff --git a/src/routes/(app)/qr/[text]/+page.server.ts b/src/routes/(app)/qr/[text]/+page.server.ts index 5a9debb3..210c3c45 100644 --- a/src/routes/(app)/qr/[text]/+page.server.ts +++ b/src/routes/(app)/qr/[text]/+page.server.ts @@ -1,3 +1,4 @@ export async function load({ params }) { - return params; + const { text } = params; + return { text }; } diff --git a/src/routes/(app)/settings/nostr/+page.server.ts b/src/routes/(app)/settings/nostr/+page.server.ts index b4bb124b..1767ebaf 100644 --- a/src/routes/(app)/settings/nostr/+page.server.ts +++ b/src/routes/(app)/settings/nostr/+page.server.ts @@ -1,9 +1,10 @@ -import { get } from "$lib/utils"; import updateUser from "$lib/settings"; +import { auth, get } from "$lib/utils"; -export const load = async () => { +export const load = async ({ cookies }) => { const { challenge } = await get("/challenge"); - return { challenge }; + const apps = await get("/apps", auth(cookies)); + return { apps, challenge }; }; export const actions = { diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte index 20c18c6f..6b4c2f12 100644 --- a/src/routes/(app)/settings/nostr/+page.svelte +++ b/src/routes/(app)/settings/nostr/+page.svelte @@ -1,10 +1,11 @@ <script> + import { untrack } from "svelte"; import { browser } from "$app/environment"; import { getNsec } from "$lib/nostr"; import { page } from "$app/stores"; import { tick } from "svelte"; import { t } from "$lib/translations"; - import { copy, fail } from "$lib/utils"; + import { s, copy, fail } from "$lib/utils"; import { PUBLIC_COINOS_PUBKEY as pk, PUBLIC_COINOS_RELAY as relay, @@ -12,9 +13,10 @@ import { signer, save } from "$lib/store"; let { data } = $props(); - let { challenge, user } = $derived(data); - let { npub } = $state(user); + let { apps, challenge, user } = $derived(data); + let npub = $state(untrack(() => user.npub)); let extensionAvailable = $derived(browser && window.nostr); + let { locale } = $derived(user); let newNsec = $state(), nsec = $state(), @@ -31,14 +33,6 @@ } }; - let lud16 = $derived(`${user.username}@${$page.url.host}`); - let nwc = $derived( - `nostr+walletconnect://${pk}?relay=${encodeURIComponent( - relay, - )}&secret=${user.nwc}&lud16=${lud16}`, - ); - let toggleNwc = () => (revealNwc = !revealNwc); - let extension = $state(); let getPubkey = async () => { $signer = { method: "extension", ready: true }; @@ -47,30 +41,114 @@ await tick(); $save.click(); }; + + let units = (r) => + ({ + daily: $t("accounts.day"), + weekly: $t("accounts.week"), + monthly: $t("accounts.month"), + yearly: $t("accounts.year"), + })[r]; </script> -<div> - <input type="hidden" name="challenge" value={challenge} /> - <input type="hidden" name="extension" value={extension} /> - <label for="nwc" class="font-bold">{$t("user.settings.nwc")}</label> +<input type="hidden" name="challenge" value={challenge} /> +<input type="hidden" name="extension" value={extension} /> - <p class="text-secondary mb-1"> +<div class="p-4"> + <h2 class="text-2xl font-bold mb-2"> + {$t("user.settings.nwc")} + </h2> + <p class="text-secondary mb-4"> {$t("user.settings.nwcDescription")} </p> - <div class="flex flex-wrap justify-center gap-2"> - <a href={nwc} class="btn grow"> - <img src="/images/nostr.png" class="w-8" /> - <div class="my-auto">{$t("user.settings.connectNostr")}</div> - </a> + <div class="space-y-2"> + {#each apps as connection, i} + {@const last = i === apps.length - 1} + <div class="p-4" class:border-b-8={!last}> + <div class="flex justify-center gap-2 items-center p-4"> + <div class="grow text-xl break-words min-w-0">{connection.name}</div> + {#if connection.max_amount > 0} + <div class="flex text-xl gap-1"> + <div class="flex items-center font-bold"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + ></iconify-icon> + {s(connection.max_amount, locale)} + </div> - {#if !revealNwc} - <button onclick={toggleNwc} type="button" class="btn grow"> - <iconify-icon noobserver icon="ph:warning-bold" width="32" - ></iconify-icon> - <div class="my-auto">{$t("accounts.revealNwc")}</div></button - > - {/if} + {#if connection.budget_renewal !== "never"} + <div> + {connection.budget_renewal} + </div> + {/if} + </div> + {/if} + </div> + + <div class="flex flex-wrap justify-center gap-1"> + <a + href={connection.nwc} + class="btn bg-gradient-to-tr from-purple-500 to-pink-500 !w-auto shrink text-white grow whitespace-nowrap" + class:btn-disabled={!connection.secret} + aria-label="Open nostr" + > + <iconify-icon icon="ph:arrow-square-out-bold" width="32" + ></iconify-icon> + <div>Connect</div> + </a> + + <div class="flex gap-1 w-full"> + <a + href={`/apps/${connection.pubkey}`} + class="btn !w-auto grow" + aria-label="Edit" + > + <iconify-icon icon="ph:pencil-bold" width="32"></iconify-icon> + </a> + + <a + href={`/apps/${connection.pubkey}/payments`} + aria-label="Payments" + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("accounts.copy")} + > + <iconify-icon icon="ph:clock-bold" width="32"></iconify-icon> + </a> + + <button + aria-label="Copy" + type="button !w-auto grow" + onclick={() => copy(connection.nwc)} + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("accounts.copy")} + > + <iconify-icon icon="ph:copy-bold" width="32"></iconify-icon> + </button> + + <a + aria-label="QR" + href={`/qr/${encodeURIComponent(connection.nwc)}`} + class="btn !w-auto grow" + class:btn-disabled={!connection.secret} + title={$t("user.receive.showQR")} + disabled={!connection.secret} + > + <iconify-icon icon="ph:qr-code-bold" width="32"></iconify-icon> + </a> + </div> + </div> + </div> + {/each} + + <a href="/apps/new" class="btn"> + <iconify-icon icon="ph:plus-bold" width="32"></iconify-icon> + {$t("accounts.newConection")} + </a> </div> </div> @@ -93,13 +171,21 @@ <div class="space-y-2"> <div class="font-bold">{$t("user.nostrPubkey")}</div> <div class="flex gap-4"> - <textarea name="pubkey" bind:value={npub}> </textarea> + <textarea name="pubkey" bind:value={npub} rows={3}> </textarea> <div class="flex my-auto gap-1"> - <button type="button" class="my-auto" onclick={() => copy(npub)} + <button + type="button" + class="my-auto btn btn-circle" + onclick={() => copy(npub)} + aria-label="Copy" ><iconify-icon noobserver icon="ph:copy-bold" width="32" ></iconify-icon></button > - <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> + <a + href={`/qr/${encodeURIComponent(npub)}`} + class="my-auto btn btn-circle" + aria-label="QR" + > <iconify-icon noobserver icon="ph:qr-code-bold" width="32" ></iconify-icon> </a> @@ -108,8 +194,7 @@ {#if extensionAvailable} <button class="btn" type="button" onclick={getPubkey}> - <iconify-icon icon="lucide-lab:bee" width="32" class="text-yellow-400" - ></iconify-icon> + <img src="/images/alby.svg" width="32" /> {$t("user.settings.syncWithExtension")}</button > {/if} diff --git a/static/images/alby.svg b/static/images/alby.svg new file mode 100644 index 00000000..db5b799d --- /dev/null +++ b/static/images/alby.svg @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="372.86301" + height="296.45599" + fill="none" + version="1.1" + id="svg6" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs6" /> + <circle + cx="-36.674999" + cy="36.674999" + r="36.674999" + fill="#000000" + transform="scale(-1,1)" + id="circle1" /> + <path + stroke="#000000" + stroke-width="18.337" + d="m 30.562998,31.173 68.46,68.46" + id="path1" /> + <circle + cx="336.18799" + cy="36.675007" + r="36.674999" + fill="#000000" + id="circle2" /> + <path + stroke="#000000" + stroke-width="18.337" + d="m 342.911,31.173 -68.46,68.46" + id="path2" /> + <path + fill="#ffdf6f" + fill-rule="evenodd" + d="m 47.084998,255.525 c -19.766,-9.41 -31.27,-30.542 -27.417,-52.092 C 36.207998,110.938 105.135,41.565 187.654,41.565 c 82.719,0 151.78,69.71 168.105,162.54 3.796,21.59 -7.801,42.712 -27.635,52.045 -42.441,19.973 -89.846,31.137 -139.859,31.137 -50.532,0 -98.402002,-11.398 -141.180002,-31.762 z" + clip-rule="evenodd" + id="path3" /> + <path + fill="#000000" + d="m 355.759,204.105 9.03,-1.588 z M 28.693998,205.047 C 44.674998,115.672 110.737,50.733 187.654,50.733 V 32.396 c -88.121002,0 -159.914002,73.808 -177.011002,169.423 l 18.05,3.228 z M 187.654,50.733 c 77.102,0 143.3,65.256 159.075,154.96 l 18.06,-3.176 C 347.914,106.56 275.989,32.396 187.654,32.396 Z M 324.22,247.854 c -41.246,19.41 -87.32,30.264 -135.955,30.264 v 18.338 c 51.39,0 100.126,-11.475 143.763,-32.01 z m -135.955,30.264 c -49.14,0 -95.666002,-11.081 -137.239002,-30.872 l -7.882,16.557 c 43.983,20.939 93.197002,32.653 145.121002,32.653 z m 158.464,-72.425 c 3.044,17.31 -6.224,34.498 -22.509,42.161 l 7.808,16.592 c 23.384,-11.004 37.31,-36.059 32.761,-61.929 z M 10.642998,201.819 c -4.6179995,25.823 9.198,50.891 32.501,61.984 l 7.882,-16.557 c -16.23,-7.726 -25.422,-24.922 -22.332,-42.199 z" + id="path4" /> + <path + fill="#000000" + fill-rule="evenodd" + d="m 82.353998,232.53 c -15.911,-6.48 -25.349,-23.409 -19.836,-39.681 17.002,-50.179 66.600002,-86.492 125.136002,-86.492 58.536,0 108.134,36.313 125.137,86.492 5.513,16.272 -3.925,33.201 -19.836,39.681 -32.498,13.235 -68.048,20.527 -105.301,20.527 -37.253,0 -72.803,-7.292 -105.300002,-20.527 z" + clip-rule="evenodd" + id="path5" /> + <ellipse + cx="229.82999" + cy="184.597" + fill="#ffffff" + rx="30.563" + ry="24.450001" + id="ellipse5" /> + <ellipse + cx="142.338" + cy="184.61198" + fill="#ffffff" + rx="30.563" + ry="24.450001" + id="ellipse6" /> +</svg> |
