archy/neode-ui/src/views/Home.vue
Dorian dad184cfc6 fix: container DNS, nginx chown, onboarding guard, seed UX, install flow
Backend:
- Add --add-host host.containers.internal:host-gateway to LND and Bitcoin
  Knots containers (fixes DNS resolution failure in rootless podman)
- Add --user 0:0 and DAC_OVERRIDE to nginx UI sidecar containers
  (fixes chown crash in rootless podman for bitcoin-ui, electrs-ui, lnd-ui)
- Add hostadd to Rust Podman API client for web UI container installs
- Add Chromium privacy flags to kiosk launcher (disable telemetry)

Frontend:
- Fix onboarding reset on raw IP visits (trust localStorage as first-class
  signal, skip boot screen when server is up but not onboarded)
- Fix seed regression: persist challenge indices in sessionStorage so going
  back from Verify doesn't change which words are asked
- Remove glass container from seed Generate/Verify/Restore screens
- Add Back button to Restore from Seed screen
- Replace Network card: Tor (purple), VPN status (orange), Bitcoin sync (orange)
- Add ElectrumX to curated app list with correct .webp icon
- Install flow: navigate to My Apps immediately with toast, hide
  installed/installing apps from marketplace and discover views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:06:57 +01:00

407 lines
28 KiB
Vue

<template>
<div class="pb-6">
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
<div class="min-h-[4.5rem]">
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
{{ line1Text }}<span v-if="showCaretLine1" class="typing-caret"></span>
</h1>
<p class="text-white/80">
{{ line2Text }}<span v-if="showCaretLine2" class="typing-caret"></span>
</p>
</div>
<!-- Desktop: tabs inline with header -->
<div
v-if="!uiMode.isChat"
role="tablist"
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
</div>
</div>
<!-- Update notification banner -->
<div
v-if="updateAvailable && !updateDismissed"
role="alert"
class="mb-6 glass-card p-4 flex items-center justify-between gap-4 border-l-4 border-orange-400 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<div class="flex items-center gap-3 min-w-0">
<svg class="w-6 h-6 text-orange-400 shrink-0" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ t('home.updateAvailable', { version: updateVersion }) }}</p>
<p v-if="updateChangelog" class="text-xs text-white/60 truncate">{{ updateChangelog }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<RouterLink to="/dashboard/settings/update" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ t('home.updateNow') }}</RouterLink>
<button @click="dismissUpdate" aria-label="Dismiss update notification" class="text-white/40 hover:text-white/80 transition-colors p-1" title="Dismiss">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<!-- Tab bar + content (all non-chat modes) -->
<template v-if="!uiMode.isChat">
<!-- Mobile: full-width tabs -->
<div
role="tablist"
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
</div>
<!-- Setup tab -->
<EasyHome v-if="homeTab === 'setup'" :show="!showWelcomeBlock || animateCards" :animate="animateCards" />
<!-- Dashboard tab: overview cards -->
<div
v-else
class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<!-- My Apps Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 0">
<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.myApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.myAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/apps" :aria-label="t('home.goToApps')" 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 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Installed / Running</p>
<p class="text-2xl font-bold text-white">{{ appCount }}/{{ runningCount }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
<div class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center" :style="app.bg ? { background: app.bg } : {}" :class="{ 'bg-white/5': !app.bg }">
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
</div>
</button>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageApps') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Cloud Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 1">
<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.cloud') }}</h2>
<p class="text-sm text-white/70">{{ t('home.cloudDesc') }}</p>
</div>
<RouterLink to="/dashboard/cloud" :aria-label="t('home.goToCloud')" 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 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg"><p class="text-xs text-white/60 mb-1">{{ t('home.storageUsed') }}</p><p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p></div>
<div class="p-4 bg-white/5 rounded-lg"><p class="text-xs text-white/60 mb-1">{{ t('home.folders') }}</p><p class="text-2xl font-bold text-white">{{ cloudFolderDisplay }}</p></div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.viewFolders') }}</RouterLink>
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.uploadFiles') }}</button>
</div>
</div>
</div>
</div>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 2">
<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.network') }}</h2>
<p class="text-sm text-white/70">{{ t('home.networkDesc') }}</p>
</div>
<RouterLink to="/dashboard/server" :aria-label="t('home.goToNetwork')" 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 space-y-3 mb-4 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="torConnected ? 'bg-purple-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Tor</span></div>
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'Connected' : 'Not configured' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageNetwork') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<!-- System Stats -->
<HomeSystemCard
:animate="animateCards"
:loaded="systemStatsLoaded"
:stats="systemStats"
:uptime-display="systemUptimeDisplay"
/>
</div>
<!-- Quick Start Goals -->
<div
v-if="homeTab === 'dashboard' && showQuickStart"
class="home-card transition-opacity duration-300"
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
style="--card-stagger: 5"
>
<div class="home-card-shell">
<div class="home-card-inner px-6 py-6">
<div class="flex items-start justify-between mb-2">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
</div>
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
<span>{{ goal.title }}</span>
</RouterLink>
</div>
</div>
</div>
</div>
</template>
<!-- Chat Mode -->
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
</div>
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { useUIModeStore } from '@/stores/uiMode'
import { PackageState } from '../types/api'
import { playTypingSound } from '@/composables/useLoginSounds'
import { GOALS } from '@/data/goals'
import EasyHome from '@/components/EasyHome.vue'
import { fileBrowserClient } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import HomeWalletCard from './home/HomeWalletCard.vue'
import HomeSystemCard from './home/HomeSystemCard.vue'
import type { WalletTransaction } from './home/HomeWalletCard.vue'
const { t } = useI18n()
const router = useRouter()
const uiMode = useUIModeStore()
const isDev = import.meta.env.DEV
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
const topGoals = GOALS.slice(0, 3)
const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
const QUICK_START_RESHOW_LOGINS = 5
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const LINE1 = t('home.title')
const LINE2 = t('home.subtitle')
const MS_PER_CHAR = 55
const displayLine1 = ref('')
const displayLine2 = ref('')
const showCaretLine1 = ref(false)
const showCaretLine2 = ref(false)
const showWelcomeBlock = ref(false)
const hasTypedWelcome = ref(false)
const animateCards = ref(false)
let typingInterval: ReturnType<typeof setInterval> | null = null
const line1Text = computed(() => showWelcomeBlock.value ? displayLine1.value : LINE1)
const line2Text = computed(() => showWelcomeBlock.value ? displayLine2.value : LINE2)
onBeforeUnmount(() => {
if (typingInterval) clearInterval(typingInterval)
if (systemStatsInterval) clearInterval(systemStatsInterval)
})
watch(() => loginTransition.pendingWelcomeTyping, (pending) => { if (pending) showWelcomeBlock.value = true })
watch(() => loginTransition.startWelcomeTyping, (shouldStart) => {
if (!shouldStart || hasTypedWelcome.value) return
hasTypedWelcome.value = true; showWelcomeBlock.value = true
displayLine1.value = ''; displayLine2.value = ''
showCaretLine1.value = true; showCaretLine2.value = false
playTypingSound(); animateCards.value = true
let i = 0
typingInterval = setInterval(() => {
if (i < LINE1.length) { displayLine1.value = LINE1.slice(0, i + 1); i++ }
else if (i < LINE1.length + LINE2.length) { showCaretLine1.value = false; showCaretLine2.value = true; displayLine2.value = LINE2.slice(0, i - LINE1.length + 1); i++ }
else { if (typingInterval) clearInterval(typingInterval); typingInterval = null; showCaretLine2.value = false; loginTransition.setStartWelcomeTyping(false) }
}, MS_PER_CHAR)
}, { immediate: true })
const packages = computed(() => store.packages)
const appCount = computed(() => Object.keys(packages.value || {}).length)
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
const quickLaunchApps = [
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
]
// Network card data
const torConnected = computed(() => {
const torAddr = store.data?.['server-info']?.['tor-address']
return !!torAddr && torAddr.length > 0
})
const vpnConnected = computed(() => {
const pkg = packages.value['tailscale']
return !!pkg && pkg.state === PackageState.Running
})
const bitcoinSyncDisplay = computed(() => {
if (!systemStats.bitcoinAvailable) return 'Not running'
if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced'
return `${systemStats.bitcoinSyncPercent.toFixed(1)}%`
})
// Quick Start
const quickStartDismissed = ref(false)
const allQuickStartAppsInstalled = computed(() => QUICK_START_APPS.every((appId) => Object.keys(packages.value).includes(appId)))
const showQuickStart = computed(() => !allQuickStartAppsInstalled.value && !quickStartDismissed.value)
function loadQuickStartState() {
try { const raw = localStorage.getItem(QUICK_START_KEY); if (!raw) { quickStartDismissed.value = false; return }; const data = JSON.parse(raw); if (!data.dismissed) { quickStartDismissed.value = false; return }; const loginCount = (data.loginCount || 0) + 1; localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount })); quickStartDismissed.value = loginCount % QUICK_START_RESHOW_LOGINS !== 0 } catch { quickStartDismissed.value = false }
}
function dismissQuickStart() { quickStartDismissed.value = true; try { localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount: 0 })) } catch { /* ignore */ } }
loadQuickStartState()
// Update notification
const updateAvailable = ref(false); const updateDismissed = ref(false); const updateVersion = ref(''); const updateChangelog = ref('')
async function checkUpdateStatus() {
try { const res = await rpcClient.call<{ update_available: boolean }>({ method: 'update.status' }); updateAvailable.value = res.update_available } catch { /* unavailable */ }
if (updateAvailable.value) { try { const detail = await rpcClient.call<{ update: { version: string; changelog: string[] } | null }>({ method: 'update.check' }); if (detail.update) { updateVersion.value = detail.update.version; updateChangelog.value = detail.update.changelog.slice(0, 2).join('; ') } } catch { /* unavailable */ } }
}
async function dismissUpdate() { updateDismissed.value = true; try { await rpcClient.call({ method: 'update.dismiss' }) } catch { /* ignore */ } }
// Cloud data
const cloudStorageUsed = ref<number | null>(null); const cloudFolderCount = ref<number | null>(null)
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]}` }
const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? formatBytes(cloudStorageUsed.value) : '...')
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
})
// Wallet modals
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false)
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0)
const walletTransactions = ref<WalletTransaction[]>([])
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
async function loadWeb5Status() {
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 }
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
}
// System stats
const systemStatsLoaded = ref(false)
const systemStats = reactive({ cpuPercent: 0, memUsed: 0, memTotal: 0, memPercent: 0, diskUsed: 0, diskTotal: 0, diskPercent: 0, uptimeSecs: 0, bitcoinSyncPercent: 0, bitcoinBlockHeight: 0, bitcoinAvailable: false })
const systemUptimeDisplay = computed(() => { if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(systemStats.uptimeSecs / 86400); const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((systemStats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
async function loadSystemStats() {
try { const res = await rpcClient.call<{ cpu_usage_percent: number; mem_used_bytes: number; mem_total_bytes: number; disk_used_bytes: number; disk_total_bytes: number; uptime_secs: number }>({ method: 'system.stats' }); systemStats.cpuPercent = res.cpu_usage_percent; systemStats.memUsed = res.mem_used_bytes; systemStats.memTotal = res.mem_total_bytes; systemStats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0; systemStats.diskUsed = res.disk_used_bytes; systemStats.diskTotal = res.disk_total_bytes; systemStats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0; systemStats.uptimeSecs = res.uptime_secs; systemStatsLoaded.value = true } catch { systemStatsLoaded.value = true }
try { const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({ method: 'bitcoin.getinfo', timeout: 5000 }); systemStats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100; systemStats.bitcoinBlockHeight = btc.block_height ?? 0; systemStats.bitcoinAvailable = true } catch { systemStats.bitcoinAvailable = false }
}
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
</script>
<style scoped>
.typing-caret::after { content: ''; display: inline-block; width: 3px; height: 1.1em; background: #fbbf24; margin-left: 2px; vertical-align: text-bottom; animation: caret-blink 0.7s step-end infinite; }
@keyframes caret-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
</style>
<style>
/* Home card styles — unscoped so they reach child components (HomeWalletCard, HomeSystemCard) */
.grid > .home-card { min-height: 280px; }
.home-card-shell { background-color: rgba(0, 0, 0, 0.65); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border-radius: 1rem; overflow: hidden; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); border: 1px solid transparent; height: 100%; }
.home-card-animate .home-card-shell { animation: card-fly-in 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; animation-delay: calc(var(--card-stagger) * 0.18s); opacity: 0; transform: translateY(50px) scale(0.92); }
@keyframes card-fly-in { 0% { opacity: 0; transform: translateY(50px) scale(0.92); border-color: transparent; } 75% { opacity: 1; transform: translateY(0) scale(1); border-color: transparent; } 100% { opacity: 1; transform: translateY(0) scale(1); border-color: rgba(255, 255, 255, 0.18); } }
.home-card-inner { overflow: hidden; opacity: 0; }
.home-card-animate .home-card-inner { animation: inner-draw 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; animation-delay: calc(var(--card-stagger) * 0.18s + 0.9s); }
@keyframes inner-draw { 0% { opacity: 0; clip-path: inset(0 100% 0 0); } 15% { opacity: 1; } 100% { opacity: 1; clip-path: inset(0 0 0 0); } }
.home-card-text { overflow: hidden; }
.home-card-stats { overflow: hidden; }
.home-card-btn { opacity: 0; transform: scale(0.5); border-color: transparent; min-height: 44px; padding-top: 10px; padding-bottom: 10px; }
.home-card-animate .home-card-btn { animation: btn-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation-delay: calc(var(--card-stagger) * 0.18s + 1.5s); }
@keyframes btn-pop { 0% { opacity: 0; transform: scale(0.5); border-color: transparent; } 85% { opacity: 1; transform: scale(1); border-color: transparent; } 100% { opacity: 1; transform: scale(1); border-color: rgba(255, 255, 255, 0.18); } }
.home-card:not(.home-card-animate) .home-card-inner, .home-card:not(.home-card-animate) .home-card-btn { opacity: 1; animation: none; clip-path: none; transform: none; border-color: rgba(255, 255, 255, 0.18); }
.home-card:not(.home-card-animate) .home-card-shell { border-color: rgba(255, 255, 255, 0.18); }
</style>