Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable lg:col-span-2"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
style="--card-stagger: 6"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/monitoring?from=home" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 gap-4 flex-1 min-h-0" :class="stats.bitcoinAvailable ? 'sm:grid-cols-4' : 'sm:grid-cols-3'">
|
||||
<template v-if="!loaded">
|
||||
<div v-for="i in 3" :key="i" class="p-4 bg-white/5 rounded-lg animate-pulse">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="w-8 h-3 bg-white/10 rounded"></div>
|
||||
<div class="w-12 h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(stats.cpuPercent)">{{ (stats.cpuPercent ?? 0).toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.cpuPercent)" :style="{ width: stats.cpuPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">{{ t('home.ram') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(stats.memPercent)">{{ formatBytes(stats.memUsed) }} / {{ formatBytes(stats.memTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.memPercent)" :style="{ width: stats.memPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">{{ t('home.disk') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(stats.diskPercent)">{{ formatBytes(stats.diskUsed) }} / {{ formatBytes(stats.diskTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.diskPercent)" :style="{ width: stats.diskPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="stats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-orange-400/80">Bitcoin</p>
|
||||
<p class="text-sm font-medium" :class="stats.bitcoinStale ? 'text-white/50' : (stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400')">
|
||||
{{ stats.bitcoinStale ? 'Updating…' : (stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="stats.bitcoinSyncPercent >= 99.9 ? 'bg-green-400' : 'bg-orange-400'" :style="{ width: Math.min(stats.bitcoinSyncPercent, 100) + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-1">Block {{ stats.bitcoinBlockHeight.toLocaleString() }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
animate: boolean
|
||||
loaded: boolean
|
||||
stats: {
|
||||
cpuPercent: number
|
||||
memUsed: number
|
||||
memTotal: number
|
||||
memPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
diskPercent: number
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean
|
||||
bitcoinStale?: boolean
|
||||
}
|
||||
uptimeDisplay: string
|
||||
}>()
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const val = bytes / Math.pow(1024, i)
|
||||
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`
|
||||
}
|
||||
|
||||
function gaugeTextColor(pct: number): string {
|
||||
if (pct >= 90) return 'text-red-400'
|
||||
if (pct >= 70) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
}
|
||||
|
||||
function gaugeBarColor(pct: number): string {
|
||||
if (pct >= 90) return 'bg-red-400'
|
||||
if (pct >= 70) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
style="--card-stagger: 4"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('web5.wallet') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="incomingTxCount > 0 ? (showIncomingTxPanel = !showIncomingTxPanel) : $emit('showTransactions')"
|
||||
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
|
||||
class="shrink-0"
|
||||
>
|
||||
<template v-if="incomingTxCount > 0">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
<span>Incoming {{ incomingTxCount }}</span>
|
||||
<span class="incoming-tx-ping"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="w-3.5 h-3.5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
<span>Transactions</span>
|
||||
</template>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('showWalletSettings')"
|
||||
aria-label="Wallet settings"
|
||||
title="Wallet settings"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<transition name="incoming-tx-slide">
|
||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
|
||||
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in incomingTransactions"
|
||||
:key="tx.tx_hash"
|
||||
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
:class="isOnchain(tx) ? 'cursor-pointer' : ''"
|
||||
@click="isOnchain(tx) && $emit('openInMempool', tx.tx_hash)"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="isOnchain(tx) && tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" :class="isOnchain(tx) && tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
|
||||
<span
|
||||
v-if="isOnchain(tx)"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
|
||||
>
|
||||
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-500/15 text-green-400"
|
||||
>
|
||||
{{ railBadge(tx) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
||||
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
|
||||
</div>
|
||||
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Inline SVG, not an emoji: kiosk images ship no color-emoji
|
||||
font, so 🥜 rendered as a tofu box on TVs -->
|
||||
<svg class="w-5 h-5 text-purple-400" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Fedimint</span>
|
||||
</div>
|
||||
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
|
||||
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-teal-400" role="img" aria-label="Ark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8a2.5 2.5 0 10-2.5-2.5A2.5 2.5 0 0012 8zm0 0v13m-7-7c0 3.87 3.13 7 7 7s7-3.13 7-7m-14 0H3m18 0h-2" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">Ark</span>
|
||||
</div>
|
||||
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<button @click="$emit('showSend')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
{{ t('common.send') }}
|
||||
</button>
|
||||
<button @click="$emit('showReceive')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Receive
|
||||
</button>
|
||||
<button @click="$emit('showTransactions')" class="home-card-btn flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Transactions
|
||||
</button>
|
||||
<button v-if="isDev" @click="$emit('faucet')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
|
||||
Faucet
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('showScan')"
|
||||
aria-label="Scan QR to send"
|
||||
title="Scan QR to send"
|
||||
class="home-card-btn px-3 py-2 glass-button rounded-lg transition-colors shrink-0 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export interface WalletTransaction {
|
||||
tx_hash: string
|
||||
amount_sats: number
|
||||
direction: 'incoming' | 'outgoing'
|
||||
num_confirmations: number
|
||||
time_stamp: number
|
||||
total_fees: number
|
||||
dest_addresses: string[]
|
||||
label: string
|
||||
block_height: number
|
||||
// Which rail the transaction happened on; absent = onchain (older backends)
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
animate: boolean
|
||||
walletConnected: boolean
|
||||
walletOnchain: number
|
||||
walletLightning: number
|
||||
walletEcash: number
|
||||
walletFedimint: number
|
||||
walletArk?: number
|
||||
walletTransactions: WalletTransaction[]
|
||||
isDev: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
showScan: []
|
||||
showSend: []
|
||||
showReceive: []
|
||||
showTransactions: []
|
||||
showWalletSettings: []
|
||||
faucet: []
|
||||
openInMempool: [txHash: string]
|
||||
}>()
|
||||
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
const walletTotal = computed(() =>
|
||||
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
|
||||
)
|
||||
|
||||
function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
// Instant rails (lightning/cashu/fedimint/ark) settle immediately — there is
|
||||
// no confirmation to wait for, so they only get the "incoming" treatment for
|
||||
// a short window after receipt instead of sitting in the badge forever.
|
||||
const INSTANT_INCOMING_WINDOW_SECS = 5 * 60
|
||||
const nowSecs = ref(Math.floor(Date.now() / 1000))
|
||||
let nowTimer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => { nowTimer = setInterval(() => { nowSecs.value = Math.floor(Date.now() / 1000) }, 30000) })
|
||||
onUnmounted(() => { if (nowTimer) clearInterval(nowTimer) })
|
||||
|
||||
const incomingTransactions = computed(() =>
|
||||
props.walletTransactions.filter(tx => {
|
||||
if (tx.direction !== 'incoming') return false
|
||||
if (isOnchain(tx)) return tx.num_confirmations < 3
|
||||
return nowSecs.value - tx.time_stamp < INSTANT_INCOMING_WINDOW_SECS
|
||||
})
|
||||
)
|
||||
const incomingTxCount = computed(() => incomingTransactions.value.length)
|
||||
|
||||
function railBadge(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Instant'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
if (tx.kind === 'fedimint') return 'Fedimint'
|
||||
if (tx.kind === 'ark') return 'Ark'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTxTime(timestamp: number): string {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getHomeRecommendedApps, isMarketplaceAppInstalled } from '../homeRecommendations'
|
||||
import type { MarketplaceApp } from '@/views/marketplace/marketplaceData'
|
||||
|
||||
const apps: MarketplaceApp[] = [
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', dockerImage: 'vaultwarden:latest' },
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
|
||||
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
|
||||
{ id: 'thunderhub', title: 'ThunderHub', dockerImage: 'thunderhub:latest' },
|
||||
{ id: 'dwn', title: 'DWN', dockerImage: 'dwn:latest' },
|
||||
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
|
||||
]
|
||||
|
||||
describe('homeRecommendations', () => {
|
||||
it('treats installed aliases as installed apps', () => {
|
||||
expect(isMarketplaceAppInstalled('mempool', { 'mempool-web': {} })).toBe(true)
|
||||
expect(isMarketplaceAppInstalled('mempool', { vaultwarden: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns uninstalled installable core and recommended apps for Home', () => {
|
||||
const recommended = getHomeRecommendedApps(apps, { 'mempool-web': {} }, 3)
|
||||
|
||||
expect(recommended.map((app) => app.id)).toEqual([
|
||||
'bitcoin-knots',
|
||||
'vaultwarden',
|
||||
'homeassistant',
|
||||
])
|
||||
})
|
||||
|
||||
it('fills from optional apps once core and recommended apps are installed', () => {
|
||||
const recommended = getHomeRecommendedApps(apps, {
|
||||
'bitcoin-knots': {},
|
||||
'mempool-web': {},
|
||||
vaultwarden: {},
|
||||
})
|
||||
|
||||
expect(recommended.map((app) => app.id)).toEqual(['homeassistant'])
|
||||
expect(recommended.some((app) => app.id === 'dwn' || app.id === 'thunderhub')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getAppTier, INSTALLED_ALIASES, type MarketplaceApp } from '@/views/marketplace/marketplaceData'
|
||||
|
||||
export type InstalledPackageMap = Record<string, unknown>
|
||||
|
||||
export function isMarketplaceAppInstalled(
|
||||
appId: string,
|
||||
installedPackages: InstalledPackageMap,
|
||||
aliases: Record<string, string[]> = INSTALLED_ALIASES,
|
||||
): boolean {
|
||||
if (appId in installedPackages) return true
|
||||
return aliases[appId]?.some((alias) => alias in installedPackages) ?? false
|
||||
}
|
||||
|
||||
export function getHomeRecommendedApps(
|
||||
apps: MarketplaceApp[],
|
||||
installedPackages: InstalledPackageMap,
|
||||
limit = 3,
|
||||
): MarketplaceApp[] {
|
||||
const candidates = apps
|
||||
.filter((app) => app.id !== 'dwn' && app.id !== 'thunderhub')
|
||||
.filter((app) => {
|
||||
if (!app.dockerImage) return false
|
||||
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const tierRank = (app: MarketplaceApp) => {
|
||||
const tier = getAppTier(app.id)
|
||||
if (tier === 'core') return 0
|
||||
if (tier === 'recommended') return 1
|
||||
return 2
|
||||
}
|
||||
const tierDiff = tierRank(a) - tierRank(b)
|
||||
if (tierDiff !== 0) return tierDiff
|
||||
return (a.title || a.id).localeCompare(b.title || b.id)
|
||||
})
|
||||
return candidates.slice(0, limit)
|
||||
}
|
||||
Reference in New Issue
Block a user