3 Commits
Author SHA1 Message Date
TZGyn d15152a013 bump version to 0.0.12 2026-08-04 22:19:25 +08:00
TZGyn 3913889a22 add portfolio allocation donut chart on dashboard 2026-08-04 22:19:13 +08:00
TZGyn da0c56658b add QR image upload to send pages 2026-08-04 22:19:03 +08:00
8 changed files with 246 additions and 15 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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>
+75
View File
@@ -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,
}
}
+69
View File
@@ -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')
}) })
@@ -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} />
+11
View File
@@ -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} />
+4 -1
View File
@@ -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=="],