Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15152a013 | ||
|
|
3913889a22 | ||
|
|
da0c56658b |
@@ -4,7 +4,7 @@ export default {
|
|||||||
app: {
|
app: {
|
||||||
name: 'koins',
|
name: 'koins',
|
||||||
identifier: 'koins.tzgyn.com',
|
identifier: 'koins.tzgyn.com',
|
||||||
version: '0.0.11',
|
version: '0.0.12',
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
// Vite builds to dist/, we copy from there
|
// Vite builds to dist/, we copy from there
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"electrobun": "^1.18.1",
|
"electrobun": "^1.18.1",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"monero-ts": "^0.11.8",
|
"monero-ts": "^0.11.8",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js'
|
||||||
|
import Loader from '$lib/components/loader.svelte'
|
||||||
|
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
|
||||||
|
import { decodeQrFromFile } from '$lib/qr.js'
|
||||||
|
|
||||||
|
let {
|
||||||
|
onDecode,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
onDecode: (text: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement
|
||||||
|
let busy = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
const handleFile = async (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
busy = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const text = await decodeQrFromFile(file)
|
||||||
|
if (!text) throw new Error('No QR code found in the image')
|
||||||
|
onDecode(text)
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to read QR code'
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
if (fileInput) fileInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFile}
|
||||||
|
disabled={disabled || busy} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 gap-1.5"
|
||||||
|
disabled={disabled || busy}
|
||||||
|
onclick={() => fileInput?.click()}>
|
||||||
|
{#if busy}
|
||||||
|
<Loader class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<QrCodeIcon size={14} />
|
||||||
|
{/if}
|
||||||
|
Scan QR
|
||||||
|
</Button>
|
||||||
|
{#if error}
|
||||||
|
<span class="text-xs text-red-500">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import jsQR from 'jsqr'
|
||||||
|
|
||||||
|
export const decodeQrFromFile = (file: File): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Canvas is not supported')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
)
|
||||||
|
resolve(jsQR(data, width, height)?.data ?? '')
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
reject(new Error('Could not load the image'))
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
|
||||||
|
export type QrParseResult = {
|
||||||
|
address: string
|
||||||
|
amount?: string
|
||||||
|
destinationTag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
|
||||||
|
// as well as bare addresses. Returns the last path-like segment before any
|
||||||
|
// query string so prefixes and /pay paths are skipped.
|
||||||
|
export const parseQrPayload = (
|
||||||
|
text: string,
|
||||||
|
kind: 'xrp' | 'monero',
|
||||||
|
): QrParseResult => {
|
||||||
|
let body = text.trim().replace(/\s+/g, '')
|
||||||
|
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
|
||||||
|
const [beforeQuery, queryRaw] = body.split('?')
|
||||||
|
const segments = beforeQuery.split('/')
|
||||||
|
const address = segments[segments.length - 1]
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (queryRaw) {
|
||||||
|
for (const pair of queryRaw.split('&')) {
|
||||||
|
const eq = pair.indexOf('=')
|
||||||
|
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
|
||||||
|
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
|
||||||
|
if (key) params[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'xrp') {
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
amount: params['amount'] || undefined,
|
||||||
|
destinationTag: params['dt'] || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
amount: params['tx_amount'] || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
import WalletIcon from '@lucide/svelte/icons/wallet'
|
import WalletIcon from '@lucide/svelte/icons/wallet'
|
||||||
import Loader from '$lib/components/loader.svelte'
|
import Loader from '$lib/components/loader.svelte'
|
||||||
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
|
||||||
|
import { arc } from 'd3-shape'
|
||||||
|
import { tweened } from 'svelte/motion'
|
||||||
|
import { cubicOut } from 'svelte/easing'
|
||||||
|
|
||||||
let feError = $state<string | null>(null)
|
let feError = $state<string | null>(null)
|
||||||
|
|
||||||
@@ -43,6 +46,33 @@
|
|||||||
|
|
||||||
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
|
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
|
||||||
|
|
||||||
|
const xrpShare = $derived(portfolioUsd > 0 ? xrpTotalUsd / portfolioUsd : 0)
|
||||||
|
const xmrShare = $derived(
|
||||||
|
portfolioUsd > 0 ? xmrTotalUsd / portfolioUsd : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const tweenedUsd = tweened(0, { duration: 600, easing: cubicOut })
|
||||||
|
const tweenedXrpShare = tweened(0, { duration: 600, easing: cubicOut })
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tweenedUsd.set(portfolioUsd)
|
||||||
|
tweenedXrpShare.set(xrpShare)
|
||||||
|
})
|
||||||
|
|
||||||
|
const donut = $derived.by(() => {
|
||||||
|
if (portfolioUsd <= 0) return { xrp: '', xmr: '' }
|
||||||
|
const a = arc<{ startAngle: number; endAngle: number }>()
|
||||||
|
.innerRadius(62)
|
||||||
|
.outerRadius(80)
|
||||||
|
.padAngle(0.02)
|
||||||
|
const start = -Math.PI / 2
|
||||||
|
const endXrp = start + $tweenedXrpShare * 2 * Math.PI
|
||||||
|
return {
|
||||||
|
xrp: a({ startAngle: start, endAngle: endXrp }) ?? '',
|
||||||
|
xmr: a({ startAngle: endXrp, endAngle: start + 2 * Math.PI }) ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const hasXrp = $derived(xrpWallet.wallets.length > 0)
|
const hasXrp = $derived(xrpWallet.wallets.length > 0)
|
||||||
const hasXmr = $derived(moneroWallet.wallets.length > 0)
|
const hasXmr = $derived(moneroWallet.wallets.length > 0)
|
||||||
const noWallets = $derived(!hasXrp && !hasXmr)
|
const noWallets = $derived(!hasXrp && !hasXmr)
|
||||||
@@ -69,6 +99,8 @@
|
|||||||
maximumFractionDigits: dp,
|
maximumFractionDigits: dp,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
frontendLog('dashboard mounted')
|
frontendLog('dashboard mounted')
|
||||||
})
|
})
|
||||||
@@ -143,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center pt-4 pb-2">
|
<div class="flex flex-col items-center pt-4 pb-2">
|
||||||
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
|
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
|
||||||
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
|
||||||
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
|
||||||
@@ -154,6 +186,43 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base">Portfolio Allocation</CardTitle>
|
||||||
|
<CardDescription class="text-xs">
|
||||||
|
Share of your balance by asset
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-col items-center gap-5 py-6">
|
||||||
|
<div class="relative">
|
||||||
|
<svg viewBox="-100 -100 200 200" class="size-48">
|
||||||
|
<path d={donut.xrp} fill="#0EA5E9" />
|
||||||
|
<path d={donut.xmr} fill="#F97316" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<p
|
||||||
|
class="text-lg font-semibold tabular-nums">$
|
||||||
|
{fmtUsd($tweenedUsd)}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Portfolio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-5 text-xs">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class="size-2.5 rounded-full bg-sky-500"></span>
|
||||||
|
XRP · {fmtPct($tweenedXrpShare)}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class="size-2.5 rounded-full bg-orange-500"></span>
|
||||||
|
XMR · {fmtPct(1 - $tweenedXrpShare)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => navigate('/xrp')}
|
onclick={() => navigate('/xrp')}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from '$lib/components/ui/select/index.js'
|
} from '$lib/components/ui/select/index.js'
|
||||||
import { navigate } from 'sv-router/generated'
|
import { navigate } from 'sv-router/generated'
|
||||||
|
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||||
|
import { parseQrPayload } from '$lib/qr.js'
|
||||||
|
|
||||||
const w = moneroWallet
|
const w = moneroWallet
|
||||||
|
|
||||||
@@ -134,7 +136,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-xs font-medium">Recipient Address</label>
|
<label class="text-xs font-medium">Recipient Address</label>
|
||||||
|
<QrUpload
|
||||||
|
onDecode={(text) => {
|
||||||
|
const parsed = parseQrPayload(text, 'monero')
|
||||||
|
recipient = parsed.address
|
||||||
|
if (parsed.amount && !sendAll) amountXmr = parsed.amount
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Monero address starting with 4..."
|
placeholder="Monero address starting with 4..."
|
||||||
bind:value={recipient} />
|
bind:value={recipient} />
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
} from '$lib/components/ui/card/index.js'
|
} from '$lib/components/ui/card/index.js'
|
||||||
import { navigate } from 'sv-router/generated'
|
import { navigate } from 'sv-router/generated'
|
||||||
|
import QrUpload from '$lib/components/qr-upload.svelte'
|
||||||
|
import { parseQrPayload } from '$lib/qr.js'
|
||||||
|
|
||||||
const w = xrpWallet
|
const w = xrpWallet
|
||||||
|
|
||||||
@@ -78,7 +80,16 @@
|
|||||||
}}
|
}}
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-xs font-medium">Recipient Address</label>
|
<label class="text-xs font-medium">Recipient Address</label>
|
||||||
|
<QrUpload
|
||||||
|
onDecode={(text) => {
|
||||||
|
const parsed = parseQrPayload(text, 'xrp')
|
||||||
|
recipient = parsed.address
|
||||||
|
if (parsed.destinationTag) destinationTag = parsed.destinationTag
|
||||||
|
if (parsed.amount) amountXrp = parsed.amount
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="XRP address starting with r..."
|
placeholder="XRP address starting with r..."
|
||||||
bind:value={recipient} />
|
bind:value={recipient} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"electrobun": "^1.18.1",
|
"electrobun": "^1.18.1",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"monero-ts": "^0.11.8",
|
"monero-ts": "^0.11.8",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -640,6 +641,8 @@
|
|||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"koins": ["koins@workspace:apps/desktop"],
|
"koins": ["koins@workspace:apps/desktop"],
|
||||||
@@ -846,7 +849,7 @@
|
|||||||
|
|
||||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|
||||||
"sv-router": ["sv-router@0.18.0", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-gLky7FBGj8P6MAna1wxYniHJxMrXgaeTNnkZ7WH387ddRyUohfDtvEgWQFfB4oLL2XxWlw8Lfmk03TtBdK/JTA=="],
|
"sv-router": ["sv-router@0.18.1", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-xMJxgDwmcWtLDopU6xdEiBICRN40ucTlK63GUZf6cQk8689LUaUD5FlMrdtTdPFRQ2kfJBeJgoaI1k+XOuIQSQ=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
|
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user