- LND UI: replace cdn.tailwindcss.com with local tailwind.css (CSP fix) - LND UI: make asset paths relative for nginx proxy compatibility - Web5 wallet: add QR code for on-chain receive addresses (qrcode npm) - Web5 wallet: hide incoming transactions after 3 confirmations - Apps: add "Services" tab to separate backend containers from user apps - Home: null guard on packages.value to prevent TypeError on load - First-boot: auto-create Bitcoin Knots wallet (no longer auto-created) - AppSession: add mempool-electrs to port mapping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
866 lines
35 KiB
Vue
866 lines
35 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"
|
|
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
|
|
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
|
role="tablist"
|
|
: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: goal-based cards -->
|
|
<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="servicesDotColor"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.servicesStatus') }}</span>
|
|
</div>
|
|
<span class="text-sm font-medium" :class="servicesStatusColor">{{ servicesStatusText }}</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="connectivityDotColor"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.connectivity') }}</span>
|
|
</div>
|
|
<span class="text-sm font-medium" :class="connectivityColor">{{ connectivityText }}</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 bg-blue-400"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.runningApps') }}</span>
|
|
</div>
|
|
<span class="text-sm text-white/80 font-medium">{{ runningCount }}</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>
|
|
|
|
<!-- Web5 Overview -->
|
|
<div
|
|
data-controller-container
|
|
tabindex="0"
|
|
class="home-card controller-focusable"
|
|
:class="{ 'home-card-animate': animateCards }"
|
|
style="--card-stagger: 3"
|
|
>
|
|
<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.web5') }}</h2>
|
|
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
|
|
</div>
|
|
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" 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="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
|
|
</div>
|
|
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</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="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
|
|
</div>
|
|
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</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 bg-white/30"></div>
|
|
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
|
|
</div>
|
|
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
|
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
|
{{ t('home.manageWeb5') }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- System Stats -->
|
|
<div
|
|
data-controller-container
|
|
tabindex="0"
|
|
class="home-card controller-focusable lg:col-span-2"
|
|
:class="{ 'home-card-animate': animateCards }"
|
|
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">
|
|
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
|
<p class="text-sm text-white/70">{{ systemUptimeDisplay }}</p>
|
|
</div>
|
|
<RouterLink to="/dashboard/server" :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 sm:grid-cols-3 gap-4 flex-1 min-h-0">
|
|
<template v-if="!systemStatsLoaded">
|
|
<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(systemStats.cpuPercent)">{{ systemStats.cpuPercent.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(systemStats.cpuPercent)" :style="{ width: systemStats.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(systemStats.memPercent)">{{ formatBytes(systemStats.memUsed) }} / {{ formatBytes(systemStats.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(systemStats.memPercent)" :style="{ width: systemStats.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(systemStats.diskPercent)">{{ formatBytes(systemStats.diskUsed) }} / {{ formatBytes(systemStats.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(systemStats.diskPercent)" :style="{ width: systemStats.diskPercent + '%' }"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Start Goals - shown in Pro mode below the overview cards -->
|
|
<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: redirect to Chat view -->
|
|
<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>
|
|
</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 { useAppStore } from '../stores/app'
|
|
|
|
const { t } = useI18n()
|
|
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'
|
|
|
|
const router = useRouter()
|
|
const uiMode = useUIModeStore()
|
|
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
|
|
const topGoals = GOALS.slice(0, 3)
|
|
|
|
// Apps required by the top 3 goals — if all installed, no need to show Quick Start
|
|
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 computed values
|
|
const servicesAllRunning = computed(() =>
|
|
appCount.value > 0 && runningCount.value === appCount.value
|
|
)
|
|
const servicesStatusText = computed(() => {
|
|
if (appCount.value === 0) return t('home.noApps')
|
|
return servicesAllRunning.value ? t('home.allRunning') : `${runningCount.value}/${appCount.value} ${t('home.runningLabel')}`
|
|
})
|
|
const servicesStatusColor = computed(() =>
|
|
appCount.value === 0 ? 'text-white/60' : servicesAllRunning.value ? 'text-green-400' : 'text-yellow-400'
|
|
)
|
|
const servicesDotColor = computed(() =>
|
|
appCount.value === 0 ? 'bg-white/40' : servicesAllRunning.value ? 'bg-green-400' : 'bg-yellow-400'
|
|
)
|
|
const connectivityText = computed(() => store.isConnected ? t('common.connected') : t('common.disconnected'))
|
|
const connectivityColor = computed(() => store.isConnected ? 'text-green-400' : 'text-red-400')
|
|
const connectivityDotColor = computed(() => store.isConnected ? 'bg-green-400' : 'bg-red-400')
|
|
|
|
// Quick Start Goals dismiss logic
|
|
const quickStartDismissed = ref(false)
|
|
|
|
const allQuickStartAppsInstalled = computed(() =>
|
|
QUICK_START_APPS.every((appId) => Object.keys(packages.value).includes(appId))
|
|
)
|
|
|
|
const showQuickStart = computed(() => {
|
|
if (allQuickStartAppsInstalled.value) return false
|
|
return !quickStartDismissed.value
|
|
})
|
|
|
|
function loadQuickStartState() {
|
|
try {
|
|
const raw = localStorage.getItem(QUICK_START_KEY)
|
|
if (!raw) { quickStartDismissed.value = false; return }
|
|
const data = JSON.parse(raw) as { dismissed: boolean; loginCount: number }
|
|
if (!data.dismissed) { quickStartDismissed.value = false; return }
|
|
// Re-show every N logins
|
|
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<{
|
|
current_version: string
|
|
update_available: boolean
|
|
update_in_progress: boolean
|
|
rollback_available: boolean
|
|
}>({ method: 'update.status' })
|
|
updateAvailable.value = res.update_available
|
|
} catch {
|
|
if (import.meta.env.DEV) console.warn('Update status check unavailable')
|
|
}
|
|
|
|
if (updateAvailable.value) {
|
|
try {
|
|
const detail = await rpcClient.call<{
|
|
current_version: string
|
|
update_available: boolean
|
|
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 {
|
|
if (import.meta.env.DEV) console.warn('Update detail check unavailable')
|
|
}
|
|
}
|
|
}
|
|
|
|
async function dismissUpdate() {
|
|
updateDismissed.value = true
|
|
try {
|
|
await rpcClient.call({ method: 'update.dismiss' })
|
|
} catch {
|
|
if (import.meta.env.DEV) console.warn('Failed to dismiss update')
|
|
}
|
|
}
|
|
|
|
// 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 (e) {
|
|
if (import.meta.env.DEV) console.warn('FileBrowser may not be running', e)
|
|
}
|
|
loadSystemStats()
|
|
systemStatsInterval = setInterval(loadSystemStats, 30000)
|
|
checkUpdateStatus()
|
|
loadWeb5Status()
|
|
})
|
|
|
|
// Web5 status (fetched from RPC instead of hardcoded)
|
|
const web5DidStatus = ref('--')
|
|
const web5DwnStatus = ref('--')
|
|
const web5CredentialCount = ref('--')
|
|
|
|
async function loadWeb5Status() {
|
|
try {
|
|
const identity = await rpcClient.call<{ did: string }>({ method: 'identity.get', timeout: 5000 })
|
|
web5DidStatus.value = identity.did ? 'Active' : 'Inactive'
|
|
} catch {
|
|
web5DidStatus.value = '--'
|
|
}
|
|
try {
|
|
const dwn = await rpcClient.call<{ status: string }>({ method: 'dwn.health', timeout: 5000 })
|
|
web5DwnStatus.value = dwn.status === 'ok' ? 'Synced' : dwn.status || '--'
|
|
} catch {
|
|
web5DwnStatus.value = '--'
|
|
}
|
|
try {
|
|
const creds = await rpcClient.call<{ credentials: unknown[] }>({ method: 'identity.list-credentials', timeout: 5000 })
|
|
web5CredentialCount.value = String(creds.credentials?.length ?? 0)
|
|
} catch {
|
|
web5CredentialCount.value = '0'
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
|
|
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`
|
|
})
|
|
|
|
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'
|
|
}
|
|
|
|
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 (e) {
|
|
if (import.meta.env.DEV) console.warn('RPC unavailable — keeping defaults', e)
|
|
systemStatsLoaded.value = true
|
|
}
|
|
}
|
|
|
|
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; }
|
|
}
|
|
|
|
/* 2advanced-style card animation sequence */
|
|
.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);
|
|
}
|
|
}
|
|
|
|
/* When not animating, show everything */
|
|
.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>
|