Let's get this started
This commit is contained in:
commit
c6ce63b6fa
46 changed files with 3983 additions and 0 deletions
145
src/routes/+layout.svelte
Normal file
145
src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,145 @@
|
|||
<script lang="ts">
|
||||
import "$lib/style.css";
|
||||
import "iconify-icon";
|
||||
import { hasSeed, passwordRequest } from "$lib/wallet.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import PasswordDialog from "$lib/components/PasswordDialog.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { start as startNwc } from "$lib/nwc.svelte";
|
||||
import SplashScreen from "$lib/components/SplashScreen.svelte";
|
||||
import ErrorDialog from "$lib/components/ErrorDialog.svelte";
|
||||
import InstallPrompt from "$lib/components/InstallPrompt.svelte";
|
||||
|
||||
type AppError = {
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
let passwordDialogOpen = $state(false);
|
||||
let showSplash = $state(true);
|
||||
let currentError = $state<AppError | null>(null);
|
||||
let showInstallPrompt = $state(false);
|
||||
let deferredPrompt: Event | undefined = $state(undefined);
|
||||
|
||||
async function handleInstallClick() {
|
||||
if (deferredPrompt) {
|
||||
(deferredPrompt as any).prompt();
|
||||
const { outcome } = await (deferredPrompt as any).userChoice;
|
||||
if (outcome === "accepted") {
|
||||
deferredPrompt = undefined;
|
||||
showInstallPrompt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnlockAttempt(pw: string) {
|
||||
if (passwordRequest.resolve) {
|
||||
passwordRequest.resolve(pw);
|
||||
passwordRequest.resolve = undefined;
|
||||
passwordRequest.reject = undefined;
|
||||
passwordRequest.pending = false;
|
||||
startNwc();
|
||||
}
|
||||
passwordDialogOpen = false;
|
||||
}
|
||||
|
||||
function goToSettings() {
|
||||
goto("/settings");
|
||||
}
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (passwordRequest.pending && !passwordDialogOpen) {
|
||||
passwordDialogOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
const isOnSetup = $derived($page.route.id?.startsWith("/setup"));
|
||||
const isOnSettings = $derived($page.route.id?.startsWith("/settings"));
|
||||
const showSettingsButton = $derived(!isOnSetup && !isOnSettings && hasSeed());
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
showSplash = false;
|
||||
}, 3000);
|
||||
|
||||
window.addEventListener("error", (event: ErrorEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.message.indexOf("Svelte error")) return;
|
||||
currentError = { message: event.message, stack: event.error?.stack };
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
"unhandledrejection",
|
||||
(event: PromiseRejectionEvent) => {
|
||||
const reason = event.reason;
|
||||
if (reason instanceof Error) {
|
||||
if (reason.message.indexOf("Svelte error")) return;
|
||||
currentError = { message: reason.message, stack: reason.stack };
|
||||
} else {
|
||||
if (String(reason).indexOf("Svelte error")) return;
|
||||
currentError = { message: String(reason) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
if (
|
||||
!window.matchMedia("(display-mode: standalone)").matches &&
|
||||
/Mobi|Android/i.test(navigator.userAgent)
|
||||
) {
|
||||
showInstallPrompt = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasSeed() && !isOnSetup) {
|
||||
goto("/setup");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showInstallPrompt && deferredPrompt}
|
||||
<InstallPrompt onInstall={handleInstallClick} />
|
||||
{:else if showSplash}
|
||||
<SplashScreen />
|
||||
{:else}
|
||||
<main class="app-wrapper">
|
||||
{#if showSettingsButton}
|
||||
<div class="settings-button-container">
|
||||
<button class="retro-btn settings-btn" onclick={goToSettings}>
|
||||
<iconify-icon icon="mdi:cog" width="16" height="16"></iconify-icon>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isOnSetup || !passwordRequest.pending}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<PasswordDialog bind:open={passwordDialogOpen} unlock={handleUnlockAttempt} />
|
||||
{/if}
|
||||
|
||||
<ErrorDialog error={currentError} onclose={() => (currentError = null)} />
|
||||
|
||||
<style>
|
||||
.settings-button-container {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
</style>
|
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
49
src/routes/+page.svelte
Normal file
49
src/routes/+page.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import type { BindingLiquidSdk } from "@breeztech/breez-sdk-liquid/web";
|
||||
import { balances, getBreezSDK, initBalances } from "$lib/breez.svelte";
|
||||
import BalanceDisplay from "$lib/components/BalanceDisplay.svelte";
|
||||
import PaymentHistory from "$lib/components/PaymentHistory.svelte";
|
||||
import ReceiveDialog from "$lib/components/ReceiveDialog.svelte";
|
||||
import SendDialog from "$lib/components/SendDialog.svelte";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import { refreshBalances } from "$lib/breez.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let breezSDK = $state<BindingLiquidSdk | undefined>(undefined);
|
||||
|
||||
let receiveDialogOpen = $state(false);
|
||||
let sendDialogOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
initBalances();
|
||||
breezSDK = await getBreezSDK();
|
||||
});
|
||||
|
||||
function openReceiveDialog() {
|
||||
receiveDialogOpen = true;
|
||||
}
|
||||
|
||||
function openSendDialog() {
|
||||
sendDialogOpen = true;
|
||||
}
|
||||
|
||||
function onPaymentSent() {
|
||||
refreshBalances();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReceiveDialog bind:open={receiveDialogOpen} />
|
||||
<SendDialog bind:open={sendDialogOpen} onsent={onPaymentSent} />
|
||||
|
||||
<div class="container">
|
||||
<BalanceDisplay
|
||||
balance={balances.balance}
|
||||
pending={balances.pendingReceive - balances.pendingSend}
|
||||
/>
|
||||
<div class="retro-card send-receive-buttons">
|
||||
<Button variant="primary" onclick={openReceiveDialog}>Receive</Button>
|
||||
<Button variant="danger" onclick={openSendDialog}>Send</Button>
|
||||
</div>
|
||||
|
||||
<PaymentHistory />
|
||||
</div>
|
|
@ -0,0 +1,64 @@
|
|||
import { error, json } from "@sveltejs/kit";
|
||||
import { MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config";
|
||||
import {
|
||||
getUser,
|
||||
isNpub,
|
||||
userExistsOrNpub,
|
||||
validateLightningInvoiceAmount,
|
||||
} from "$lib";
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ params, url }) {
|
||||
const { username } = params;
|
||||
const amount = url.searchParams.get("amount");
|
||||
|
||||
if (!username || !amount) {
|
||||
throw error(400, "User and amount parameters are required");
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9\-_]+$/.test(username)) {
|
||||
throw error(400, "Invalid username format");
|
||||
}
|
||||
|
||||
if (!(await userExistsOrNpub(username))) {
|
||||
throw error(404, "User not found");
|
||||
}
|
||||
|
||||
if (
|
||||
!amount || !validateLightningInvoiceAmount(amount, { isMillisats: true })
|
||||
) {
|
||||
throw error(
|
||||
400,
|
||||
`Amount must be between ${MIN_SATS_RECEIVE} and ${MAX_SATS_RECEIVE} millisats`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = isNpub(username) ? username : (await getUser(username))?.npub;
|
||||
|
||||
if (!user) {
|
||||
throw error(404, "User not found");
|
||||
}
|
||||
|
||||
const npubxRequest =
|
||||
`https://npubx.cash/.well-known/lnurlp/${user}?amount=${amount}`;
|
||||
|
||||
const response = await fetch(npubxRequest);
|
||||
|
||||
if (!response.ok) {
|
||||
throw error(500, "Failed to create swap");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
139
src/routes/.well-known/lnurlp/[username]/+server.ts
Normal file
139
src/routes/.well-known/lnurlp/[username]/+server.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { error, json } from "@sveltejs/kit";
|
||||
import { BASE_DOMAIN, MAX_SATS_RECEIVE, MIN_SATS_RECEIVE } from "$lib/config";
|
||||
import {
|
||||
isNpub,
|
||||
userExists,
|
||||
userExistsOrNpub,
|
||||
validateLightningInvoiceAmount,
|
||||
} from "$lib";
|
||||
import { getDb } from "$lib/database";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
interface LNURLPayRequest {
|
||||
callback: string;
|
||||
minSendable: number;
|
||||
maxSendable: number;
|
||||
metadata: string;
|
||||
tag: "payRequest";
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ params, url, request }) {
|
||||
const { username } = params;
|
||||
const amount = url.searchParams.get("amount");
|
||||
|
||||
if (!/^[a-z0-9\-_.]+$/.test(username)) {
|
||||
throw error(400, "Invalid username format");
|
||||
}
|
||||
|
||||
if (!(await userExistsOrNpub(username))) {
|
||||
throw error(404, "User not found");
|
||||
}
|
||||
|
||||
if (
|
||||
amount && !validateLightningInvoiceAmount(amount, { isMillisats: true })
|
||||
) {
|
||||
throw error(
|
||||
400,
|
||||
`Amount must be between ${MIN_SATS_RECEIVE} and ${MAX_SATS_RECEIVE} millisats`,
|
||||
);
|
||||
}
|
||||
|
||||
const domain = request.headers.get("host") || new URL(BASE_DOMAIN).host;
|
||||
const identifier = `${username}@${domain}`;
|
||||
|
||||
const response: LNURLPayRequest = {
|
||||
callback: `https://${domain}/.well-known/lnurl-pay/callback/${
|
||||
encodeURIComponent(
|
||||
username,
|
||||
)
|
||||
}`,
|
||||
minSendable: MIN_SATS_RECEIVE * 1000,
|
||||
maxSendable: MAX_SATS_RECEIVE * 1000,
|
||||
metadata: JSON.stringify([
|
||||
["text/plain", `Pay ${username}`],
|
||||
["text/identifier", identifier],
|
||||
]),
|
||||
tag: "payRequest",
|
||||
};
|
||||
|
||||
return json(response);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ params, url, request }) {
|
||||
// TODO: creating a username should cost a small amount of sats
|
||||
// 105k sats for 1 character names
|
||||
// 63k sats for 2 character names
|
||||
// 42k sats for 3 character names
|
||||
// 21k sats for 4 character names
|
||||
// 10k sats otherwise
|
||||
|
||||
const { username } = params;
|
||||
if (username.length > 32) {
|
||||
throw error(400, "Username is too long. Maximum length is 32 characters.");
|
||||
}
|
||||
if (username.length < 5) {
|
||||
throw error(
|
||||
400,
|
||||
"Username is too short. Minimum length is 5 characters. Shorter usernames are reserved for future use.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9\-_]+$/.test(username)) {
|
||||
throw error(
|
||||
400,
|
||||
"Invalid username format. Only lowercase letters, numbers, and hyphens are allowed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (await userExists(username)) {
|
||||
throw error(400, "User already exists");
|
||||
}
|
||||
|
||||
if (isNpub(username)) {
|
||||
throw error(
|
||||
400,
|
||||
"Username is a valid npub. Please use a username instead.",
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
const body = await request.json();
|
||||
const { npub }: {
|
||||
npub: `npub1${string}`;
|
||||
} = body;
|
||||
|
||||
if (!npub) {
|
||||
throw error(400, "Missing npub");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(npub);
|
||||
if (decoded.type !== "npub") {
|
||||
throw error(400, "Invalid npub");
|
||||
}
|
||||
} catch {
|
||||
throw error(400, "Invalid npub");
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO users (username, npub) VALUES (?, ?)",
|
||||
[username, npub],
|
||||
);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
33
src/routes/api/check-username/+server.ts
Normal file
33
src/routes/api/check-username/+server.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { error, json, type RequestHandler } from "@sveltejs/kit";
|
||||
import { getDb } from "$lib/database";
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const npub = url.searchParams.get("npub");
|
||||
|
||||
if (!npub) {
|
||||
throw error(400, "Missing npub parameter");
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
const result = await db.execute(
|
||||
"SELECT username FROM users WHERE npub = ?",
|
||||
[npub],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
return json({
|
||||
username: result.rows[0].username,
|
||||
exists: true,
|
||||
});
|
||||
} else {
|
||||
return json({
|
||||
username: null,
|
||||
exists: false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Database error:", err);
|
||||
throw error(500, "Database error");
|
||||
}
|
||||
};
|
49
src/routes/settings/+page.svelte
Normal file
49
src/routes/settings/+page.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import LightningSettings from "$lib/components/LightningSettings.svelte";
|
||||
import NostrWalletConnect from "$lib/components/NostrWalletConnect.svelte";
|
||||
import DangerZone from "$lib/components/DangerZone.svelte";
|
||||
|
||||
function goBack() {
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="retro-card">
|
||||
<div class="header">
|
||||
<h1>Settings</h1>
|
||||
<Button onclick={goBack}>Back</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LightningSettings />
|
||||
|
||||
<NostrWalletConnect />
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
203
src/routes/setup/+page.svelte
Normal file
203
src/routes/setup/+page.svelte
Normal file
|
@ -0,0 +1,203 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
import {
|
||||
createMnemonic,
|
||||
encryptMnemonic,
|
||||
storeEncryptedSeed,
|
||||
cacheMnemonic,
|
||||
isValidMnemonic,
|
||||
} from "$lib/wallet.svelte";
|
||||
import { start as startNwc } from "$lib/nwc.svelte";
|
||||
|
||||
let stage: "choice" | "mnemonic" | "existing" | "password" | "done" =
|
||||
"choice";
|
||||
let password: string;
|
||||
let confirmPassword: string;
|
||||
let passwordError = "";
|
||||
let userMnemonic = "";
|
||||
let mnemonicError = "";
|
||||
|
||||
function chooseNewSeed() {
|
||||
userMnemonic = createMnemonic();
|
||||
stage = "mnemonic";
|
||||
}
|
||||
|
||||
function chooseExistingSeed() {
|
||||
stage = "existing";
|
||||
}
|
||||
|
||||
function continueToPassword() {
|
||||
stage = "password";
|
||||
}
|
||||
|
||||
function validateAndContinue() {
|
||||
mnemonicError = "";
|
||||
const trimmedMnemonic = userMnemonic.trim();
|
||||
|
||||
if (!trimmedMnemonic) {
|
||||
mnemonicError = "Please enter your seed phrase";
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedMnemonic = cleanMnemonicInput(trimmedMnemonic);
|
||||
|
||||
if (!isValidMnemonic(cleanedMnemonic)) {
|
||||
mnemonicError =
|
||||
"Invalid seed phrase. Please check your words and try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
userMnemonic = cleanedMnemonic;
|
||||
stage = "password";
|
||||
}
|
||||
|
||||
function cleanMnemonicInput(input: string): string {
|
||||
const numberedPattern = /\d+\.\s*([a-zA-Z]+)/g;
|
||||
const matches = input.match(numberedPattern);
|
||||
|
||||
if (matches) {
|
||||
const words = matches.map((match) =>
|
||||
match.replace(/\d+\.\s*/, "").trim()
|
||||
);
|
||||
return words.join(" ");
|
||||
}
|
||||
|
||||
return input.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
async function finishSetup() {
|
||||
passwordError = "";
|
||||
if (!password || password.length < 8) {
|
||||
passwordError = "Password must be at least 8 characters";
|
||||
return;
|
||||
}
|
||||
if (!confirmPassword || password !== confirmPassword) {
|
||||
passwordError = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptMnemonic(userMnemonic, password);
|
||||
storeEncryptedSeed(encrypted);
|
||||
cacheMnemonic(userMnemonic);
|
||||
|
||||
startNwc();
|
||||
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if stage === "choice"}
|
||||
<h1>Wallet Setup</h1>
|
||||
<div class="warning">
|
||||
This is a web wallet. Your funds <strong>ARE</strong> at risk. Store only amounts
|
||||
you are willing to lose. For larger amounts, use a hardware wallet.
|
||||
</div>
|
||||
<p>Choose how you want to set up your wallet:</p>
|
||||
<div class="choice-buttons">
|
||||
<Button variant="primary" onclick={chooseNewSeed}>Create New Seed</Button>
|
||||
<Button variant="secondary" onclick={chooseExistingSeed}>
|
||||
Enter Existing Seed
|
||||
</Button>
|
||||
</div>
|
||||
{:else if stage === "mnemonic"}
|
||||
<h1>Wallet Setup</h1>
|
||||
<div class="warning">
|
||||
This is a web wallet. Your funds <strong>ARE</strong> at risk. Store only amounts
|
||||
you are willing to lose. For larger amounts, use a hardware wallet.
|
||||
</div>
|
||||
<p>
|
||||
Please write down these 12 words in order and keep them safe. They are the
|
||||
only way to recover your funds.
|
||||
</p>
|
||||
<div class="mnemonic">
|
||||
{#each userMnemonic.split(" ") as word, i}
|
||||
<div>{i + 1}. {word}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<Button variant="primary" onclick={continueToPassword}>
|
||||
I have saved my seed
|
||||
</Button>
|
||||
</div>
|
||||
{:else if stage === "existing"}
|
||||
<h1>Enter Existing Seed</h1>
|
||||
<p>Enter your existing 12-word seed phrase to restore your wallet.</p>
|
||||
<div>
|
||||
<textarea
|
||||
class="retro-input seed-input"
|
||||
placeholder="Enter your 12-word seed phrase separated by spaces"
|
||||
bind:value={userMnemonic}
|
||||
oninput={(e) => {
|
||||
userMnemonic = cleanMnemonicInput(
|
||||
(e.target as HTMLTextAreaElement).value
|
||||
);
|
||||
}}
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
{#if mnemonicError}
|
||||
<p style="color: red;">{mnemonicError}</p>
|
||||
{/if}
|
||||
<div style="margin-top: 1rem;">
|
||||
<Button variant="primary" onclick={validateAndContinue}>Continue</Button>
|
||||
</div>
|
||||
{:else if stage === "password"}
|
||||
<h1>Set Password</h1>
|
||||
<p>
|
||||
Choose a password (min 8 characters). It will encrypt your seed locally.
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
class="retro-input"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
class="retro-input"
|
||||
placeholder="Confirm Password"
|
||||
bind:value={confirmPassword}
|
||||
/>
|
||||
</div>
|
||||
{#if passwordError}
|
||||
<p style="color: red;">{passwordError}</p>
|
||||
{/if}
|
||||
<div style="margin-top: 1rem;">
|
||||
<Button variant="primary" onclick={finishSetup}>Finish Setup</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.choice-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.seed-input {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
}
|
||||
.mnemonic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
background: #111;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.warning {
|
||||
background: #330000;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
color: #ff5555;
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue