Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<div v-if="pkg">
|
||||
<AppHeroSection
|
||||
:pkg="pkg"
|
||||
:app-id="appId"
|
||||
:package-key="packageKey"
|
||||
:can-launch="canLaunch"
|
||||
:is-web-only="isWebOnly"
|
||||
:pending-action="pendingAction"
|
||||
@launch="launchApp"
|
||||
@start="startApp"
|
||||
@stop="stopApp"
|
||||
@restart="restartApp"
|
||||
@uninstall="uninstallApp"
|
||||
@update="updateApp"
|
||||
@channels="router.push('/dashboard/apps/lnd/channels')"
|
||||
/>
|
||||
|
||||
<LndSeedBackup v-if="packageKey === 'lnd' && pkg.installed" />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<AppContentSection
|
||||
:pkg="pkg"
|
||||
:features="features"
|
||||
:needs-bitcoin-sync="needsBitcoinSync"
|
||||
:bitcoin-synced="bitcoinSynced"
|
||||
:bitcoin-sync-percent="bitcoinSyncPercent"
|
||||
:bitcoin-block-height="bitcoinBlockHeight"
|
||||
/>
|
||||
|
||||
<AppSidebar
|
||||
:pkg="pkg"
|
||||
:package-key="packageKey"
|
||||
:is-web-only="isWebOnly"
|
||||
:gateway-state="gatewayState"
|
||||
:interface-addresses="interfaceAddresses"
|
||||
:lan-url="lanUrl"
|
||||
:tor-url="torUrl"
|
||||
:show-tor-address="showTorAddress"
|
||||
:credentials="credentials"
|
||||
:credentials-loading="credentialsLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="pendingAction === 'uninstall'"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="confirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
import LndSeedBackup from './appDetails/LndSeedBackup.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||
import {
|
||||
WEB_ONLY_APP_URLS,
|
||||
PACKAGE_ALIASES,
|
||||
BITCOIN_DEPENDENT_APPS,
|
||||
resolvePackageKey,
|
||||
isRealOnionAddress,
|
||||
} from './appDetails/appDetailsData'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = route.params.id
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
|
||||
|
||||
const pkg = computed(() => {
|
||||
const routeId = appId.value
|
||||
const pkgKey = resolvePackageKey(routeId)
|
||||
if (store.packages[pkgKey]) return store.packages[pkgKey]
|
||||
if (store.packages[routeId]) return store.packages[routeId]
|
||||
const aliases = PACKAGE_ALIASES[routeId]
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (store.packages[alias]) return store.packages[alias]
|
||||
}
|
||||
}
|
||||
if (dummyApps[routeId]) return dummyApps[routeId]
|
||||
return null
|
||||
})
|
||||
|
||||
const interfaceAddresses = computed(() => {
|
||||
const main = pkg.value?.installed?.['interface-addresses']?.main
|
||||
if (!main) return null
|
||||
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
||||
return main
|
||||
})
|
||||
|
||||
const lanUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['lan-address']
|
||||
if (!addr) return '#'
|
||||
if (addr.includes('localhost')) return addr.replace('localhost', window.location.hostname)
|
||||
return addr
|
||||
})
|
||||
|
||||
const torUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['tor-address']
|
||||
if (!addr || !isRealOnionAddress(addr)) return ''
|
||||
return addr.startsWith('http') ? addr : `http://${addr}`
|
||||
})
|
||||
|
||||
const showTorAddress = computed(() => isRealOnionAddress(interfaceAddresses.value?.['tor-address']))
|
||||
|
||||
const packageKey = computed(() => resolvePackageKey(appId.value))
|
||||
|
||||
const gatewayState = computed(() => {
|
||||
const gw = store.packages['fedimint-gateway']
|
||||
return gw ? gw.state : 'not installed'
|
||||
})
|
||||
|
||||
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
|
||||
const bitcoinSyncPercent = ref(0)
|
||||
const bitcoinBlockHeight = ref(0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||
const credentialsLoading = ref(false)
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
async function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 5000,
|
||||
})
|
||||
bitcoinSyncPercent.value = (btc.sync_progress ?? 0) * 100
|
||||
bitcoinBlockHeight.value = btc.block_height ?? 0
|
||||
} catch {
|
||||
bitcoinSyncPercent.value = 0
|
||||
bitcoinBlockHeight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
credentialsLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
timeout: 5000,
|
||||
})
|
||||
credentials.value = resolveAppCredentials(packageKey.value, result)
|
||||
} catch {
|
||||
credentials.value = resolveAppCredentials(packageKey.value, null)
|
||||
} finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBitcoinSync()
|
||||
loadCredentials()
|
||||
})
|
||||
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
const uninstallModal = ref({ show: false, appTitle: '' })
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
const backButtonText = computed(() => {
|
||||
if (route.query.from === 'discover') return 'Back to Discover'
|
||||
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
||||
return t('appDetails.backToApps')
|
||||
})
|
||||
|
||||
const canLaunch = computed(() => {
|
||||
if (!pkg.value) return false
|
||||
if (isWebOnly.value) return true
|
||||
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
||||
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||
})
|
||||
|
||||
const features = computed(() => [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default'
|
||||
])
|
||||
|
||||
function goBack() {
|
||||
if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (route.query.from === 'marketplace') {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
}
|
||||
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
const id = appId.value
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebsitePackage(id, pkg.value)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (runtimeUrl) {
|
||||
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
|
||||
return
|
||||
}
|
||||
|
||||
// Container apps should launch through session routing so protocol/path
|
||||
// handling stays centralized in appSessionConfig.
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
|
||||
async function startApp() {
|
||||
pendingAction.value = 'start'
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp() {
|
||||
pendingAction.value = 'stop'
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
pendingAction.value = 'restart'
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
pendingAction.value = 'update'
|
||||
try {
|
||||
await store.updatePackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function showUninstallModal() {
|
||||
if (!pkg.value) return
|
||||
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
||||
}
|
||||
|
||||
async function confirmUninstall(deleteAppData: boolean) {
|
||||
uninstallModal.value.show = false
|
||||
pendingAction.value = 'uninstall'
|
||||
try {
|
||||
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function uninstallApp() {
|
||||
showUninstallModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
||||
<p class="text-white/70">
|
||||
Container registries this node pulls app images from. The primary is tried first; if it's
|
||||
slow or unreachable, the next one in the list is tried automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Registry list -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Registries</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
|
||||
@click="openAddRegistry"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Registries are tried in priority order on every app install. Changing the primary takes
|
||||
effect on the next install — existing containers keep running on whatever image they
|
||||
already pulled.
|
||||
</p>
|
||||
<ul v-if="registries.length" class="space-y-2">
|
||||
<li
|
||||
v-for="r in sortedRegistries"
|
||||
:key="r.url"
|
||||
class="p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<p class="text-sm font-medium text-white truncate">{{ r.name || r.url }}</p>
|
||||
<span
|
||||
v-if="r.priority === 0"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
|
||||
>PRIMARY</span>
|
||||
<span
|
||||
v-if="!r.tls_verify"
|
||||
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
|
||||
title="TLS verification disabled — HTTP or self-signed registry"
|
||||
>HTTP</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 font-mono break-all">{{ r.url }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registryTests[r.url]?.testing"
|
||||
title="Test reachability"
|
||||
@click="testRegistry(r)"
|
||||
>
|
||||
<svg v-if="registryTests[r.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="r.priority !== 0"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
|
||||
title="Make primary"
|
||||
@click="setPrimary(r.url)"
|
||||
>
|
||||
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="registries.length > 1"
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
title="Remove registry"
|
||||
@click="removeRegistry(r.url)"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
|
||||
class="mt-2 pt-2 border-t border-white/5 text-xs"
|
||||
>
|
||||
<span v-if="registryTests[r.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Reachable (HTTP {{ registryTests[r.url]?.status }})
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="truncate">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Add-registry modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="addingRegistry"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||
@click.self="cancelAddRegistry"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
|
||||
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
|
||||
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
|
||||
added to the end of the list; use "Make primary" to reorder.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitRegistry">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name</label>
|
||||
<input
|
||||
v-model="registryDraft.name"
|
||||
type="text"
|
||||
placeholder="My private registry"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
|
||||
<input
|
||||
v-model="registryDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="host:port/namespace"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
|
||||
<input
|
||||
v-model="registryDraft.tls_verify"
|
||||
type="checkbox"
|
||||
class="accent-orange-400"
|
||||
/>
|
||||
Verify TLS certificate (uncheck for HTTP or self-signed)
|
||||
</label>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddRegistry"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="registrySaving || !registryDraft.url.trim()"
|
||||
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Registry {
|
||||
url: string
|
||||
name: string
|
||||
tls_verify: boolean
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface RegistryTestState {
|
||||
testing?: boolean
|
||||
reachable?: boolean
|
||||
status?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const registries = ref<Registry[]>([])
|
||||
const sortedRegistries = computed(() =>
|
||||
[...registries.value].sort((a, b) => a.priority - b.priority)
|
||||
)
|
||||
const registryTests = ref<Record<string, RegistryTestState>>({})
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const addingRegistry = ref(false)
|
||||
const registrySaving = ref(false)
|
||||
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadRegistries() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
|
||||
registries.value = res.registries
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('registry.list failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddRegistry() {
|
||||
registryDraft.url = ''
|
||||
registryDraft.name = ''
|
||||
registryDraft.tls_verify = true
|
||||
addingRegistry.value = true
|
||||
}
|
||||
function cancelAddRegistry() {
|
||||
addingRegistry.value = false
|
||||
}
|
||||
|
||||
async function submitRegistry() {
|
||||
const url = registryDraft.url.trim()
|
||||
if (!url) return
|
||||
registrySaving.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.add',
|
||||
params: {
|
||||
url,
|
||||
name: registryDraft.name.trim() || url,
|
||||
tls_verify: registryDraft.tls_verify,
|
||||
},
|
||||
})
|
||||
registries.value = res.registries
|
||||
addingRegistry.value = false
|
||||
showStatus('Registry added.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Add registry failed: ${msg}`, true)
|
||||
} finally {
|
||||
registrySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRegistry(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.remove',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Registry removed.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setPrimary(url: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ registries: Registry[] }>({
|
||||
method: 'registry.set-primary',
|
||||
params: { url },
|
||||
})
|
||||
registries.value = res.registries
|
||||
showStatus('Primary registry updated. Next install will try it first.')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
showStatus(`Set primary failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function testRegistry(r: Registry) {
|
||||
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
url: string
|
||||
reachable: boolean
|
||||
status: number | null
|
||||
error?: string | null
|
||||
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: {
|
||||
testing: false,
|
||||
reachable: res.reachable,
|
||||
status: res.status,
|
||||
error: res.error ?? null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
registryTests.value = {
|
||||
...registryTests.value,
|
||||
[r.url]: { testing: false, reachable: false, error: msg },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadRegistries() })
|
||||
</script>
|
||||
@@ -0,0 +1,720 @@
|
||||
<template>
|
||||
<!-- The root stays in the layout as a rect placeholder; the session itself
|
||||
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
|
||||
subtree on panel<->overlay switches, and moving an iframe node reloads
|
||||
it — inline mode is now emulated with a fixed-position rect synced from
|
||||
this placeholder, so the iframe never moves in the DOM. -->
|
||||
<div class="app-session-root" ref="rootRef">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
:style="inlineRectStyle"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
ref="sessionRef"
|
||||
:class="panelClasses"
|
||||
@click.stop
|
||||
>
|
||||
<AppSessionHeader
|
||||
:app-title="appTitle"
|
||||
:is-refreshing="isRefreshing"
|
||||
:display-mode="displayMode"
|
||||
@go-back="iframeGoBack"
|
||||
@go-forward="iframeGoForward"
|
||||
@refresh="refresh"
|
||||
@open-new-tab="openNewTab"
|
||||
@close="closeSession"
|
||||
@set-mode="setMode"
|
||||
/>
|
||||
|
||||
<AppSessionFrame
|
||||
ref="frameRef"
|
||||
:app-url="appUrl"
|
||||
:app-id="appId"
|
||||
:app-title="appTitle"
|
||||
:app-icon="appIcon"
|
||||
:loading="loading"
|
||||
:iframe-blocked="iframeBlocked"
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
:electrs-sync="electrsSync"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
/>
|
||||
|
||||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||||
<MobileGamepad
|
||||
v-if="isMobile && appId === 'botfights'"
|
||||
:iframe-ref="iframeRef ?? null"
|
||||
:player="1"
|
||||
@refresh="refresh"
|
||||
@openBrowser="openNewTab"
|
||||
@close="closeSession"
|
||||
/>
|
||||
<div v-else class="md:hidden app-session-mobile-bar">
|
||||
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Forward" @click="iframeGoForward">
|
||||
<svg 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>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
||||
<svg 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>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Close" @click="closeSession">
|
||||
<svg 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>
|
||||
|
||||
<NostrIdentityPicker
|
||||
:show="showIdentityPicker"
|
||||
:app-name="appTitle"
|
||||
@select="identity.onIdentitySelected"
|
||||
@cancel="showIdentityPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||
import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
|
||||
pathProp?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
/** True when rendered inline via store (panel mode), false when route-based */
|
||||
const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
|
||||
const sessionRef = ref<HTMLElement | null>(null)
|
||||
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
||||
const loading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeBlocked = ref(false)
|
||||
const refreshKey = ref(0)
|
||||
const showIdentityPicker = ref(false)
|
||||
const autoRetryCount = ref(0)
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/dashboard/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
// Display mode -- per-app user choice → per-app default → last global → panel
|
||||
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||
const appIcon = computed(() =>
|
||||
packageEntry.value
|
||||
? resolveAppIcon(appId.value, packageEntry.value)
|
||||
: `/assets/img/app-icons/${appId.value}.png`
|
||||
)
|
||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
||||
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||||
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||||
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||||
// In the demo, apps backed by a real external site that blocks iframing open
|
||||
// in a new tab rather than the in-app session frame. Demoable apps are served
|
||||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
const isElectrsApp = computed(() =>
|
||||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||||
)
|
||||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||||
useElectrsSync()
|
||||
const electrsSync = computed(() =>
|
||||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||||
)
|
||||
watch(
|
||||
isElectrsApp,
|
||||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||||
{ immediate: true }
|
||||
)
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
'jellyfin',
|
||||
'immich',
|
||||
'photoprism',
|
||||
'filebrowser',
|
||||
])
|
||||
|
||||
const appUrl = computed(() => {
|
||||
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
|
||||
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.replace(fallbackPath).catch(() => {})
|
||||
}
|
||||
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
function setMode(mode: DisplayMode) {
|
||||
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
displayMode.value = mode
|
||||
// Strictly per-app: the pick is remembered for THIS app only (no global
|
||||
// key — one app's mode must never change how another opens).
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||||
|
||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||
// the app keeps floating above the dashboard instead of owning the route.
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
router.push(fallbackPath).then(() => {
|
||||
launcher.panelAppId = id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Reactive classes based on display mode. The store-driven session honors the
|
||||
// selected display mode in place: panel renders inline beside the page,
|
||||
// overlay/fullscreen render above it — the underlying route never changes.
|
||||
// Mobile always uses the full overlay.
|
||||
const inlinePanelMode = computed(() =>
|
||||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||||
)
|
||||
|
||||
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
|
||||
// to the placeholder's box so "inline" looks identical to the old in-place
|
||||
// render while the iframe stays put in the DOM across mode switches.
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
let rectObserver: ResizeObserver | null = null
|
||||
|
||||
function syncInlineRect() {
|
||||
const el = rootRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
// A hidden/unmounted placeholder measures 0x0 — keep the last good rect.
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||
}
|
||||
}
|
||||
|
||||
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||||
if (!inlinePanelMode.value) return undefined
|
||||
const r = inlineRect.value
|
||||
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||||
if (!r) {
|
||||
const hidden: Record<string, string> = { visibility: 'hidden' }
|
||||
return hidden
|
||||
}
|
||||
const style: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
top: `${r.top}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
zIndex: '100',
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
watch(inlinePanelMode, (on) => {
|
||||
if (on) void nextTick(syncInlineRect)
|
||||
})
|
||||
|
||||
const backdropClasses = computed(() => {
|
||||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
})
|
||||
|
||||
const panelClasses = computed(() => {
|
||||
const base = 'app-session-panel glass-card'
|
||||
if (inlinePanelMode.value) return `${base} app-session-inline`
|
||||
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
autoRetryCount.value = 0
|
||||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||
// Check if iframe actually loaded content (same-origin only)
|
||||
iframeCheckId = setTimeout(() => {
|
||||
try {
|
||||
const iframe = frameRef.value?.iframeRef
|
||||
const doc = iframe?.contentDocument
|
||||
if (doc) {
|
||||
const body = doc.body
|
||||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin -- can't check, assume OK
|
||||
}
|
||||
}, 1000)
|
||||
identity.onIframeLoadIdentity()
|
||||
}
|
||||
|
||||
function onError() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
iframeBlocked.value = true
|
||||
// Auto-retry up to 6 times (60s total) for apps that are still starting
|
||||
if (!mustOpenNewTab.value && autoRetryCount.value < 6) {
|
||||
autoRetryId = setTimeout(() => {
|
||||
autoRetryCount.value++
|
||||
refresh()
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
isRefreshing.value = true
|
||||
loading.value = true
|
||||
iframeBlocked.value = false
|
||||
refreshKey.value++
|
||||
startLoadTimeout()
|
||||
}
|
||||
|
||||
function startLoadTimeout() {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (loading.value) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
function openNewTabAndBack() {
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function openNewTab() {
|
||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
|
||||
}
|
||||
|
||||
function iframeGoForward() {
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
closeSession()
|
||||
}
|
||||
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
else closeSession()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||
displayMode.value = 'overlay'
|
||||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
||||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||||
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
|
||||
}
|
||||
|
||||
// Enter fullscreen on mount if mode is fullscreen
|
||||
watch(displayMode, (mode) => {
|
||||
if (mode !== 'fullscreen') return
|
||||
// The panel may teleport to <body> on this mode change — request fullscreen
|
||||
// after the DOM settles so we grab the element at its new location.
|
||||
void nextTick(() => {
|
||||
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
|
||||
// Open them directly instead of showing a "this app opens in a tab"
|
||||
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
|
||||
// or new tab (PWA). Then dismiss the (empty) session surface.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
if (isMobile.value) openInAppOrNewTab(appUrl.value)
|
||||
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('resize', updateIsMobile)
|
||||
window.addEventListener('resize', syncInlineRect)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
// Track the placeholder's box (sidebar collapse, layout shifts) for the
|
||||
// inline-mode fixed-position emulation. Sync before first paint so the
|
||||
// inline backdrop never flashes at the wrong rect.
|
||||
syncInlineRect()
|
||||
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
rectObserver = new ResizeObserver(syncInlineRect)
|
||||
rectObserver.observe(rootRef.value)
|
||||
}
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
startLoadTimeout()
|
||||
}
|
||||
if (displayMode.value === 'fullscreen') {
|
||||
requestAnimationFrame(() => {
|
||||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||||
})
|
||||
}
|
||||
if (screensaverSuppressedApps.has(appId.value)) {
|
||||
screensaverStore.suppress(screensaverReason.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (autoRetryId) clearTimeout(autoRetryId)
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('resize', updateIsMobile)
|
||||
window.removeEventListener('resize', syncInlineRect)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
rectObserver?.disconnect()
|
||||
rectObserver = null
|
||||
screensaverStore.resume(screensaverReason.value)
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Unscoped so children can use these classes */
|
||||
.app-session-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Inline panel mode -- fills content area, no blur, original layout */
|
||||
.app-session-backdrop-inline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-session-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-inline {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.app-session-inline {
|
||||
border-radius: 1rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay mode -- covers entire viewport including sidebar */
|
||||
.app-session-backdrop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-backdrop-overlay {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-session-overlay {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-session-overlay {
|
||||
max-width: calc(100vw - 5rem);
|
||||
max-height: calc(100vh - 5rem);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.app-session-fullscreen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Shared */
|
||||
.app-session-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-session-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.app-session-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Active display-mode button in the header bar */
|
||||
.app-session-btn-active {
|
||||
color: #fb923c;
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
.app-session-btn-active:hover {
|
||||
color: #fb923c;
|
||||
background: rgba(251, 146, 60, 0.18);
|
||||
}
|
||||
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.content-fade-enter-from,
|
||||
.content-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-session-frame-scroll-host {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-inline {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-overlay,
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--visual-viewport-height, 100dvh);
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
background: black;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.app-session-backdrop-overlay {
|
||||
padding: 0;
|
||||
backdrop-filter: none;
|
||||
background: black;
|
||||
}
|
||||
.app-session-frame-safe {
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(var(--visual-viewport-height, 100dvh) - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile bottom browser bar — sized like the main tab bar.
|
||||
Uses !important-free display so Tailwind md:hidden can override. */
|
||||
@media (min-width: 768px) {
|
||||
.app-session-mobile-bar { display: none !important; }
|
||||
}
|
||||
.app-session-mobile-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2600;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-session-inline .app-session-mobile-bar {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
.app-session-bar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.app-session-bar-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.app-session-bar-btn:active {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,903 @@
|
||||
<template>
|
||||
<div class="apps-view pb-6">
|
||||
<!-- Nav header -- tabs + categories + search -->
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
<div ref="appsHeaderRef" class="app-header-desktop items-center gap-4 relative">
|
||||
<div ref="appsPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && !collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in serviceCategoriesWithItems"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="apps-category-select">My Apps category</label>
|
||||
<select
|
||||
id="apps-category-select"
|
||||
v-model="selectedCategory"
|
||||
class="segmented-select-control"
|
||||
>
|
||||
<option
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
:value="category.id"
|
||||
>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="appsCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact: tabs for tablet, categories, search + sideload -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mobile-category-strip mb-3" aria-label="My Apps categories">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'services' && serviceCategoriesWithItems.length > 1" class="mobile-category-strip mb-3" aria-label="Services categories">
|
||||
<button
|
||||
v-for="category in serviceCategoriesWithItems"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<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="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="isLoadingApps" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Loading apps</h3>
|
||||
<p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Error -->
|
||||
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<div class="alert-error mb-4">{{ connectionError }}</div>
|
||||
<button
|
||||
@click="connectionError = ''; store.connectWebSocket()"
|
||||
class="glass-button px-6 py-3 rounded-lg font-medium"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container scanner still warming up -->
|
||||
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
|
||||
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-12 max-w-md mx-auto">
|
||||
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
|
||||
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
{{ t('apps.browseAppStore') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceMatches.length > 0" class="mb-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="discover-terminal-tag">app store</span>
|
||||
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="app in marketplaceMatches"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
|
||||
@click="openMarketplaceResult(app)"
|
||||
>
|
||||
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
|
||||
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-white truncate">{{ app.title }}</p>
|
||||
<p class="text-xs text-white/50 truncate">Available in App Store</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
|
||||
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Refreshing container state. Showing the last known app list until the scan finishes.</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: iPhone-style icon grid -->
|
||||
<div class="apps-icon-grid-mobile">
|
||||
<AppIconGrid
|
||||
:apps="filteredPackageEntries as [string, PackageDataEntry][]"
|
||||
@go-to-app="goToApp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Card grid -->
|
||||
<div class="apps-card-grid-desktop grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||
<AppCard
|
||||
v-for="([id, pkg], index) in filteredPackageEntries"
|
||||
:key="id"
|
||||
:id="id as string"
|
||||
:pkg="pkg"
|
||||
:index="index"
|
||||
:show-stagger="showStagger"
|
||||
:is-loading="!!actions.loadingActions.value[id as string]"
|
||||
:is-installing="serverStore.isInstalling(id as string)"
|
||||
:install-progress="serverStore.installingApps.get(id as string)"
|
||||
:is-uninstalling="actions.uninstallingApps.has(id as string)"
|
||||
@go-to-app="goToApp"
|
||||
@launch="launchApp"
|
||||
@start="actions.startApp"
|
||||
@stop="actions.stopApp"
|
||||
@restart="actions.restartApp"
|
||||
@update="updateApp"
|
||||
@show-uninstall="showUninstallModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="actions.uninstalling.value"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeSideload"
|
||||
>
|
||||
<form class="sideload-modal" @submit.prevent="submitSideload">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
|
||||
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="sideload-label">App ID</span>
|
||||
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Docker image</span>
|
||||
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="sideload-label">Title</span>
|
||||
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Port mapping</span>
|
||||
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Description</span>
|
||||
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, the Archipelago app registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
|
||||
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
|
||||
{{ sideloading ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actions.actionError.value }}</span>
|
||||
<button @click="actions.actionError.value = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// Module-level -- persists across component unmount/remount within same session
|
||||
let appsAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { useLastKnownPackages } from './apps/appPackageCache'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
buildServiceCategories, useServiceCategories,
|
||||
} from './apps/appsConfig'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
const sideloadForm = ref({
|
||||
id: '',
|
||||
image: '',
|
||||
title: '',
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<AppsTab>(
|
||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'services' : 'apps'
|
||||
)
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
activeTab.value = tab === 'websites' || tab === 'services' ? 'services' : 'apps'
|
||||
})
|
||||
|
||||
// Search (debounced)
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = ref('')
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(searchQuery, (val) => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
|
||||
})
|
||||
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
|
||||
|
||||
// Category filter (shared by My Apps and Services; reset when switching tabs so
|
||||
// an apps-category selection never carries into the Services sub-nav).
|
||||
const selectedCategory = ref('all')
|
||||
watch(activeTab, () => { selectedCategory.value = 'all' })
|
||||
|
||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
|
||||
|
||||
const livePackages = computed(() => store.packages || {})
|
||||
// Field missing from server data = not scanned yet (consistent with Discover/Marketplace)
|
||||
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||
// Escape hatch: never show "Checking containers…" forever — after a timeout,
|
||||
// fall through to the real (empty) state even if the scanned flag never arrives.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
const {
|
||||
packages: stablePackages,
|
||||
isUsingLastKnownPackages,
|
||||
} = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
||||
const packages = computed(() => {
|
||||
const realPackages = stablePackages.value
|
||||
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
||||
|
||||
// Inject placeholder entries for apps being installed that aren't in backend data yet
|
||||
for (const [appId, progress] of serverStore.installingApps) {
|
||||
if (!merged[appId]) {
|
||||
merged[appId] = {
|
||||
state: 'installing' as PackageState,
|
||||
manifest: {
|
||||
id: appId,
|
||||
title: progress.title,
|
||||
version: '',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
|
||||
'support-site': '', 'marketing-site': '', 'donation-url': null,
|
||||
},
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
|
||||
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||
const serviceCategoriesWithItems = useServiceCategories(packages, SERVICE_CATEGORIES)
|
||||
const appsHeaderRef = ref<HTMLElement | null>(null)
|
||||
const appsPrimaryRef = ref<HTMLElement | null>(null)
|
||||
const appsCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
appsHeaderRef,
|
||||
appsPrimaryRef,
|
||||
appsCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
const curatedApps = getCuratedAppList()
|
||||
const marketplaceMatches = computed(() => {
|
||||
const q = debouncedSearchQuery.value.trim().toLowerCase()
|
||||
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
|
||||
return curatedApps.filter(app => {
|
||||
if (isInstalledInMyApps(app.id)) return false
|
||||
return app.title?.toLowerCase().includes(q) ||
|
||||
app.id.toLowerCase().includes(q) ||
|
||||
app.author?.toLowerCase().includes(q) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
|
||||
}).slice(0, 6)
|
||||
})
|
||||
|
||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||
const isCheckingContainers = computed(() => (
|
||||
store.hasLoadedInitialData &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
!isUsingLastKnownPackages.value &&
|
||||
sortedPackageEntries.value.length === 0 &&
|
||||
!searchQuery.value &&
|
||||
!containersScanned.value
|
||||
))
|
||||
|
||||
// Connection error state
|
||||
const connectionError = ref('')
|
||||
let connectionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
appsAnimationDone = true
|
||||
if (!store.isConnected) {
|
||||
connectionTimer = setTimeout(() => {
|
||||
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
|
||||
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (connectionTimer) clearTimeout(connectionTimer)
|
||||
})
|
||||
|
||||
// Sorted entries: web-only first, then alphabetical by title
|
||||
const sortedPackageEntries = computed(() => {
|
||||
const entries = Object.entries(packages.value)
|
||||
const filtered = filterEntriesForTab(entries, activeTab.value, selectedCategory.value)
|
||||
return filtered.sort(([idA, a], [idB, b]) => {
|
||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
||||
if (aWeb !== bWeb) return aWeb - bWeb
|
||||
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPackageEntries = computed(() => {
|
||||
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
|
||||
const q = debouncedSearchQuery.value.toLowerCase()
|
||||
return sortedPackageEntries.value.filter(([id, pkg]) =>
|
||||
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
|
||||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
|
||||
id.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalledInMyApps(appId: string): boolean {
|
||||
if (appId in packages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some(alias => alias in packages.value) : false
|
||||
}
|
||||
|
||||
function openMarketplaceResult(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
|
||||
}
|
||||
|
||||
// Uninstall modal
|
||||
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
||||
|
||||
function showUninstallModal(id: string, pkg: PackageDataEntry) {
|
||||
uninstallModal.value = { show: true, appId: id, appTitle: pkg.manifest.title }
|
||||
}
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
async function onConfirmUninstall(deleteAppData: boolean) {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||
}
|
||||
|
||||
async function launchApp(id: string) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id)
|
||||
if (shown) return
|
||||
launchAppNow(id)
|
||||
}
|
||||
|
||||
function launchAppNow(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (pkg && webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
// Demo: demoable apps are served same-origin by the mock backend, so the
|
||||
// tab-launch list (real apps with framing headers) doesn't apply.
|
||||
if (!isMobile && pkg && opensInTab(id) && !(IS_DEMO && isDemoApp(id))) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredentialModal() {
|
||||
credentialModal.value.show = false
|
||||
}
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
closeCredentialModal()
|
||||
if (id) launchAppNow(id)
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
async function updateApp(id: string) {
|
||||
try {
|
||||
await serverStore.updatePackage(id)
|
||||
} catch (err) {
|
||||
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideload() {
|
||||
if (sideloading.value) return
|
||||
showSideload.value = false
|
||||
sideloadError.value = ''
|
||||
}
|
||||
|
||||
function inferPortMapping(image: string): string {
|
||||
const lower = image.toLowerCase()
|
||||
if (lower.includes('excalidraw')) return '3009:80'
|
||||
if (lower.includes('stirling')) return '3011:8080'
|
||||
if (lower.includes('freshrss')) return '3012:80'
|
||||
if (lower.includes('wallabag')) return '3013:80'
|
||||
if (lower.includes('hedgedoc')) return '3014:3000'
|
||||
if (lower.includes('cyberchef')) return '3015:80'
|
||||
if (lower.includes('mealie')) return '3016:9000'
|
||||
if (lower.includes('pairdrop')) return '3017:3000'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitSideload() {
|
||||
const id = sideloadForm.value.id.trim().toLowerCase()
|
||||
const image = sideloadForm.value.image.trim()
|
||||
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
|
||||
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
|
||||
return
|
||||
}
|
||||
if (!image || !image.includes('/')) {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
const validationError = validateSideloadRequest(id, port, store.packages)
|
||||
if (validationError) {
|
||||
sideloadError.value = validationError
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
containerConfig.title = title
|
||||
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
|
||||
if (port) containerConfig.ports = [port]
|
||||
try {
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Sideload queued...',
|
||||
attempt: 0,
|
||||
})
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id,
|
||||
dockerImage: image,
|
||||
version: 'sideload',
|
||||
containerConfig,
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
closeSideload()
|
||||
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
|
||||
} catch (err) {
|
||||
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: sideloadError.value,
|
||||
attempt: 0,
|
||||
})
|
||||
} finally {
|
||||
sideloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-icon-btn:hover,
|
||||
.sideload-icon-btn:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.sideload-icon-btn-mobile {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.sideload-modal {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
/* Bottom sheet on mobile (flush with the screen edge, so only the top
|
||||
corners round); the md: breakpoint below switches to a centered
|
||||
floating card, matching the wrapper's own items-end -> md:items-center
|
||||
layout switch, so it gets fully rounded corners like every other modal. */
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
.sideload-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-close-btn:hover,
|
||||
.sideload-close-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
.sideload-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.sideload-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal / AppIconGrid credential modal. The body
|
||||
scrolls if content overflows rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" 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>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator while iframe loads -->
|
||||
<Transition name="fade">
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- AIUI iframe — on mobile, leave room for close bar + tab bar at bottom -->
|
||||
<iframe
|
||||
v-if="aiuiUrl"
|
||||
ref="aiuiFrame"
|
||||
:src="aiuiUrl"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
allow="microphone"
|
||||
style="background: transparent"
|
||||
/>
|
||||
|
||||
<!-- Fallback when no AIUI URL configured -->
|
||||
<div v-else class="chat-placeholder">
|
||||
<div class="chat-placeholder-inner">
|
||||
<div class="chat-placeholder-icon">
|
||||
<svg class="w-8 h-8 text-white/40" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/30">
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
|
||||
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
|
||||
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
|
||||
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
|
||||
return ''
|
||||
})
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
try {
|
||||
const expected = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
if (event.origin !== expected) return
|
||||
} catch { return }
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
if (aiuiUrl.value) {
|
||||
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
|
||||
broker.start()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chat-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #fb923c;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,924 @@
|
||||
<template>
|
||||
<div class="apps-view pb-6">
|
||||
|
||||
<!-- Nav header — tabs + categories + search, matching the Apps layout -->
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search on one row -->
|
||||
<div class="app-header-desktop items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>{{ tab.name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="category in CATEGORIES"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search your files and peer files…"
|
||||
aria-label="Search files"
|
||||
data-controller-no-submit
|
||||
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: full-width tab switcher (distinct from the category pills),
|
||||
category pill strip, then search. .mobile-category-strip opts the
|
||||
pills out of the dashboard's swipe-to-switch-page gesture. -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="cloud-tab-switcher cloud-tab-switcher-full mb-3">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
class="cloud-tab-btn"
|
||||
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
|
||||
type="button"
|
||||
>{{ tab.name }}</button>
|
||||
</div>
|
||||
<div v-if="showCategories" class="mobile-category-strip mb-3" aria-label="File categories">
|
||||
<button
|
||||
v-for="category in CATEGORIES"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search your files and peer files…"
|
||||
aria-label="Search files"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full min-w-0 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Search results (any tab, when a query is active) ═════════════ -->
|
||||
<div v-if="searchActive">
|
||||
<div v-if="searching" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching your files and peers…
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="filteredSearchResults.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
No files match “{{ searchQuery }}”.
|
||||
</div>
|
||||
<div v-else class="cloud-file-list">
|
||||
<template v-for="r in filteredSearchResults" :key="r.key">
|
||||
<!-- Own files act like real file rows: actions + click opens the file -->
|
||||
<FileCard
|
||||
v-if="r.item"
|
||||
:item="r.item"
|
||||
@delete="handleDelete"
|
||||
@share="handleShare"
|
||||
@play="handlePlay"
|
||||
@preview="(p: string) => handlePreview(p, searchMineItems)"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })"
|
||||
>
|
||||
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
|
||||
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm text-white truncate">{{ r.name }}</span>
|
||||
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
|
||||
</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ r.peerName }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ My Files — every own file, flat, with real file actions ═════════════ -->
|
||||
<div v-else-if="activeTab === 'mine'">
|
||||
<div v-if="myFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading your files…
|
||||
</div>
|
||||
<div v-else-if="!fileBrowserRunning" class="glass-card p-8 text-center">
|
||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="filteredMyFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
{{ selectedCategory === 'all' ? 'No files yet — upload some from the Folders tab.' : 'No files in this category.' }}
|
||||
</div>
|
||||
<div v-else class="cloud-file-list">
|
||||
<FileCard
|
||||
v-for="item in filteredMyFiles"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
@delete="handleDelete"
|
||||
@share="handleShare"
|
||||
@play="handlePlay"
|
||||
@preview="(p: string) => handlePreview(p, filteredMyFiles)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Paid Files tab — everything this node has purchased ═════════════
|
||||
Source of truth is the purchase cache (content.owned-list): filename,
|
||||
type, price paid and when — the file itself was also auto-filed into
|
||||
Photos/Music/Documents at purchase time (2026-07-22). -->
|
||||
<div v-else-if="activeTab === 'paid'">
|
||||
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases…</div>
|
||||
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
Nothing purchased yet — files you buy from peers appear here and are saved into your folders automatically.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="it in paidItems"
|
||||
:key="it.onion + it.content_id"
|
||||
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
@click="viewPaidItem(it)"
|
||||
>
|
||||
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
|
||||
<p class="text-[11px] text-white/40">
|
||||
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
|
||||
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
|
||||
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||
<div v-else-if="activeTab === 'peers'">
|
||||
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Fetching files from {{ peerNodes.length || '' }} peer{{ peerNodes.length === 1 ? '' : 's' }}…
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center">
|
||||
<p class="text-white/60 mb-3">No peers yet. Set up federation to browse files shared by other nodes.</p>
|
||||
<RouterLink to="/dashboard/server/federation" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open Federation
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="f in filteredPeerFiles"
|
||||
:key="f.key"
|
||||
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: f.peerOnion } })"
|
||||
>
|
||||
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(f.category).iconBg">
|
||||
<svg class="w-5 h-5" :class="categoryMeta(f.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(p, i) in categoryMeta(f.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm text-white truncate">{{ f.filename }}</span>
|
||||
<span class="block text-[11px] text-white/40 truncate">{{ formatSize(f.sizeBytes) }}<template v-if="f.priceSats"> · {{ f.priceSats.toLocaleString() }} sats</template></span>
|
||||
</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
||||
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ═════════════ Folders — section (+ peer) cards ═════════════ -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="section in contentSections"
|
||||
:key="section.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="openSection(section)"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section.iconBg">
|
||||
<svg class="w-7 h-7" :class="section.iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in section.iconPaths"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">{{ section.name }}</h3>
|
||||
<p class="text-xs text-white/50">{{ section.description }}</p>
|
||||
</div>
|
||||
<!-- Arrow indicator -->
|
||||
<svg class="w-5 h-5 text-white/30" 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>
|
||||
</div>
|
||||
|
||||
<!-- App status -->
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="isAppRunning(section.appId) ? 'bg-green-500/15 text-green-400' : 'bg-white/5 text-white/40'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="isAppRunning(section.appId) ? 'bg-green-400' : 'bg-white/30'"></span>
|
||||
{{ section.appLabel }}
|
||||
</span>
|
||||
<span v-if="!isAppRunning(section.appId)" class="text-white/30">Not installed</span>
|
||||
<span v-else-if="countsLoading" class="text-white/30 animate-pulse">Loading...</span>
|
||||
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual Peer Cards -->
|
||||
<div
|
||||
v-for="peer in peerNodes"
|
||||
:key="peer.did"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3>
|
||||
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" 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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing peer nodes...
|
||||
</div>
|
||||
|
||||
<!-- No Peers placeholder (only if no peers found) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
|
||||
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" 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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
|
||||
No peers yet
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="loadError" class="alert-error mt-4">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<!-- Not Installed Hint (Folders tab only — My Files has its own) -->
|
||||
<div v-if="!fileBrowserRunning && !searchActive && activeTab === 'folders'" class="glass-card p-8 mt-6 text-center">
|
||||
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Share with peers -->
|
||||
<ShareModal
|
||||
v-if="shareTarget"
|
||||
:filename="shareTarget.name"
|
||||
:filepath="shareTarget.path"
|
||||
:is-dir="shareTarget.isDir"
|
||||
@close="shareTarget = null"
|
||||
@saved="shareTarget = null"
|
||||
/>
|
||||
|
||||
<!-- Media viewer for own files -->
|
||||
<MediaLightbox
|
||||
v-if="lightboxIndex !== null"
|
||||
:items="lightboxItems"
|
||||
:start-index="lightboxIndex"
|
||||
:show="lightboxIndex !== null"
|
||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||
:stream-url="cloudStore.streamUrl"
|
||||
@close="lightboxIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import FileCard from '../components/cloud/FileCard.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const cloudStore = useCloudStore()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const sectionCounts = ref<Record<string, number>>({})
|
||||
const countsLoading = ref(false)
|
||||
|
||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
||||
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
||||
|
||||
const TABS: Array<{ id: TabId; name: string }> = [
|
||||
{ id: 'folders', name: 'Folders' },
|
||||
{ id: 'mine', name: 'My Files' },
|
||||
{ id: 'peers', name: 'Peer Files' },
|
||||
{ id: 'paid', name: 'Paid Files' },
|
||||
]
|
||||
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'photos', name: 'Photos & Video' },
|
||||
{ id: 'music', name: 'Music' },
|
||||
{ id: 'documents', name: 'Documents' },
|
||||
]
|
||||
|
||||
const activeTab = ref<TabId>('folders')
|
||||
|
||||
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
||||
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
||||
const paidItems = ref<PaidItem[]>([])
|
||||
const paidLoading = ref(false)
|
||||
async function loadPaidItems() {
|
||||
paidLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
|
||||
paidItems.value = (res.items || []).slice().reverse()
|
||||
} catch { paidItems.value = [] } finally { paidLoading.value = false }
|
||||
}
|
||||
async function viewPaidItem(it: PaidItem) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) return
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const mime = res.mime_type || it.mime_type
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||
// Music ALWAYS plays in the global bottom-bar player — never a popup/
|
||||
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||
if (mime.startsWith('audio/')) {
|
||||
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
||||
}
|
||||
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
||||
|
||||
const selectedCategory = ref<CategoryId>('all')
|
||||
const searchQuery = ref('')
|
||||
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||
// Categories narrow file LISTS; the Folders tab is already organized by kind.
|
||||
const showCategories = computed(() => activeTab.value !== 'folders' || searchActive.value)
|
||||
|
||||
interface PeerNode {
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
name?: string
|
||||
trust_level: string
|
||||
}
|
||||
|
||||
const peerNodes = ref<PeerNode[]>([])
|
||||
const peersLoading = ref(true)
|
||||
const loadError = ref('')
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
const packages = store.packages
|
||||
if (packages[appId]?.state === 'running') return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (packages[alias]?.state === 'running') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const fileBrowserRunning = computed(() => isAppRunning('filebrowser'))
|
||||
|
||||
interface ContentSection {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
appId: string
|
||||
appLabel: string
|
||||
iconPaths: string[]
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
}
|
||||
|
||||
const contentSections: ContentSection[] = [
|
||||
{
|
||||
id: 'photos',
|
||||
name: 'Photos & Videos',
|
||||
description: 'Auto-backup & browse your media',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
iconBg: 'bg-blue-500/15',
|
||||
iconColor: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
name: 'Music',
|
||||
description: 'Your music collection',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
iconBg: 'bg-orange-500/15',
|
||||
iconColor: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
name: 'Documents',
|
||||
description: 'Files, docs & spreadsheets',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
iconBg: 'bg-green-500/15',
|
||||
iconColor: 'text-green-400',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
name: 'All Files',
|
||||
description: 'Browse your server file system',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
||||
iconBg: 'bg-white/10',
|
||||
iconColor: 'text-white/70',
|
||||
},
|
||||
]
|
||||
|
||||
const SECTION_PATHS: Record<string, string> = {
|
||||
photos: '/Photos',
|
||||
music: '/Music',
|
||||
documents: '/Documents',
|
||||
files: '/',
|
||||
}
|
||||
|
||||
// ── Category helpers ─────────────────────────────────────────────────────────
|
||||
function categoryOf(nameOrMime: string): Exclude<CategoryId, 'all'> {
|
||||
const s = nameOrMime.toLowerCase()
|
||||
if (s.startsWith('image/') || s.startsWith('video/') || /\.(jpe?g|png|gif|webp|heic|svg|mp4|mov|mkv|webm|avi)$/.test(s)) return 'photos'
|
||||
if (s.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/.test(s)) return 'music'
|
||||
return 'documents'
|
||||
}
|
||||
|
||||
const FALLBACK_SECTION: ContentSection = contentSections[2]!
|
||||
function categoryMeta(cat: Exclude<CategoryId, 'all'>): ContentSection {
|
||||
return contentSections.find(s => s.id === cat) ?? FALLBACK_SECTION
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
// ── My Files (flat list of every own file across the sections) ──────────────
|
||||
const myFiles = ref<FileBrowserItem[]>([])
|
||||
const myFilesLoading = ref(false)
|
||||
const myFilesLoaded = ref(false)
|
||||
|
||||
/** Depth-limited walk of the section folders; flat file list, capped. */
|
||||
async function loadMyFiles(force = false) {
|
||||
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
|
||||
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
|
||||
myFilesLoading.value = true
|
||||
try {
|
||||
const ok = await cloudStore.init()
|
||||
if (!ok) return
|
||||
const out: FileBrowserItem[] = []
|
||||
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
|
||||
if (sectionId === 'files') continue // '/' would double-visit the sections
|
||||
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
|
||||
while (queue.length > 0 && out.length < 500) {
|
||||
const { path, depth } = queue.shift()!
|
||||
let items: FileBrowserItem[]
|
||||
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
|
||||
for (const item of items) {
|
||||
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
|
||||
if (item.isDir) {
|
||||
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
|
||||
} else {
|
||||
out.push({ ...item, path: itemPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
myFiles.value = out
|
||||
myFilesLoaded.value = true
|
||||
} finally {
|
||||
myFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMyFiles = computed(() =>
|
||||
selectedCategory.value === 'all'
|
||||
? myFiles.value
|
||||
: myFiles.value.filter(f => categoryOf(f.name) === selectedCategory.value),
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'mine') void loadMyFiles()
|
||||
if (tab === 'peers') void loadPeerFiles()
|
||||
if (tab === 'folders') selectedCategory.value = 'all' // no hidden filter behind the cards
|
||||
})
|
||||
|
||||
// ── File actions shared by My Files rows and own-file search results ────────
|
||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
const lightboxItems = ref<FileBrowserItem[]>([])
|
||||
|
||||
function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
/** Music opens in the global bottom-bar player (GlobalAudioPlayer in App.vue). */
|
||||
async function handlePlay(path: string, name: string) {
|
||||
const url = await cloudStore.streamUrl(path)
|
||||
audioPlayer.play(url, name)
|
||||
}
|
||||
|
||||
function handlePreview(path: string, context: FileBrowserItem[]) {
|
||||
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||
const clicked = context.find(item => item.path === path)
|
||||
if (clicked) {
|
||||
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||
void handlePlay(path, clicked.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MediaLightbox filters to media internally; index within that filtered list.
|
||||
const mediaItems = context.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
const cat = getFileCategory(ext, item.isDir)
|
||||
return cat === 'image' || cat === 'video' || cat === 'audio'
|
||||
})
|
||||
const idx = mediaItems.findIndex(item => item.path === path)
|
||||
lightboxItems.value = context
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
try {
|
||||
await cloudStore.deleteItem(path)
|
||||
myFiles.value = myFiles.value.filter(f => f.path !== path)
|
||||
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : 'Delete failed'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer files (aggregated across every federation peer) ────────────────────
|
||||
interface PeerFileEntry {
|
||||
key: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
priceSats: number
|
||||
category: Exclude<CategoryId, 'all'>
|
||||
peerName: string
|
||||
peerOnion: string
|
||||
}
|
||||
|
||||
const peerFiles = ref<PeerFileEntry[]>([])
|
||||
const peerFilesLoading = ref(false)
|
||||
const peerFilesLoaded = ref(false)
|
||||
const peerFilesErrors = ref(0)
|
||||
|
||||
interface CatalogItem {
|
||||
id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
description: string
|
||||
access: string | { paid: { price_sats: number } }
|
||||
}
|
||||
|
||||
function priceOf(access: CatalogItem['access']): number {
|
||||
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
|
||||
}
|
||||
|
||||
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
|
||||
async function loadPeerFiles(force = false) {
|
||||
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
|
||||
peerFilesLoading.value = true
|
||||
peerFilesErrors.value = 0
|
||||
try {
|
||||
if (peerNodes.value.length === 0) await loadPeers()
|
||||
const results = await Promise.allSettled(
|
||||
peerNodes.value.map(async (peer) => {
|
||||
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion: peer.onion },
|
||||
timeout: 30000,
|
||||
})
|
||||
return { peer, items: res?.items ?? [] }
|
||||
}),
|
||||
)
|
||||
const merged: PeerFileEntry[] = []
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
|
||||
const { peer, items } = r.value
|
||||
const peerName = peer.name || peerDisplayName(peer.did)
|
||||
for (const item of items) {
|
||||
merged.push({
|
||||
key: `${peer.onion}:${item.id}`,
|
||||
filename: item.filename,
|
||||
sizeBytes: item.size_bytes,
|
||||
priceSats: priceOf(item.access),
|
||||
category: categoryOf(item.mime_type || item.filename),
|
||||
peerName,
|
||||
peerOnion: peer.onion,
|
||||
})
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
peerFiles.value = merged
|
||||
peerFilesLoaded.value = true
|
||||
} finally {
|
||||
peerFilesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPeerFiles = computed(() =>
|
||||
selectedCategory.value === 'all'
|
||||
? peerFiles.value
|
||||
: peerFiles.value.filter(f => f.category === selectedCategory.value),
|
||||
)
|
||||
|
||||
// ── Search (own files + all peer files) ─────────────────────────────────────
|
||||
interface SearchResult {
|
||||
key: string
|
||||
name: string
|
||||
detail: string
|
||||
category: Exclude<CategoryId, 'all'>
|
||||
item?: FileBrowserItem
|
||||
peerName?: string
|
||||
peerOnion?: string
|
||||
}
|
||||
|
||||
const searching = ref(false)
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchSeq = 0
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!searchActive.value) { searchResults.value = []; searching.value = false; return }
|
||||
searching.value = true
|
||||
searchTimer = setTimeout(() => void runSearch(), 350)
|
||||
})
|
||||
|
||||
async function runSearch() {
|
||||
const query = searchQuery.value.trim()
|
||||
const seq = ++searchSeq
|
||||
searching.value = true
|
||||
try {
|
||||
// Both corpora are cached flat lists after their first load.
|
||||
await Promise.all([loadMyFiles(), loadPeerFiles()])
|
||||
if (seq !== searchSeq) return // a newer query superseded this run
|
||||
const q = query.toLowerCase()
|
||||
const mine: SearchResult[] = myFiles.value
|
||||
.filter(f => f.name.toLowerCase().includes(q))
|
||||
.map(f => ({
|
||||
key: `mine:${f.path}`,
|
||||
name: f.name,
|
||||
detail: f.path,
|
||||
category: categoryOf(f.name),
|
||||
item: f,
|
||||
}))
|
||||
const peers: SearchResult[] = peerFiles.value
|
||||
.filter(f => f.filename.toLowerCase().includes(q))
|
||||
.map(f => ({
|
||||
key: `peer:${f.key}`,
|
||||
name: f.filename,
|
||||
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
|
||||
category: f.category,
|
||||
peerName: f.peerName,
|
||||
peerOnion: f.peerOnion,
|
||||
}))
|
||||
searchResults.value = [...mine, ...peers]
|
||||
} finally {
|
||||
if (seq === searchSeq) searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSearchResults = computed(() =>
|
||||
selectedCategory.value === 'all'
|
||||
? searchResults.value
|
||||
: searchResults.value.filter(r => r.category === selectedCategory.value),
|
||||
)
|
||||
|
||||
/** Own-file items among the current search results (lightbox context). */
|
||||
const searchMineItems = computed(() =>
|
||||
filteredSearchResults.value.flatMap(r => (r.item ? [r.item] : [])),
|
||||
)
|
||||
|
||||
// ── Existing counts / peers loading ──────────────────────────────────────────
|
||||
async function loadCounts() {
|
||||
if (!fileBrowserRunning.value) return
|
||||
countsLoading.value = true
|
||||
try {
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) return
|
||||
for (const section of contentSections) {
|
||||
const path = SECTION_PATHS[section.id]
|
||||
if (!path) continue
|
||||
try {
|
||||
const items = await fileBrowserClient.listDirectory(path)
|
||||
sectionCounts.value[section.id] = items.length
|
||||
} catch {
|
||||
sectionCounts.value[section.id] = 0
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
|
||||
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
|
||||
} finally {
|
||||
countsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCounts()
|
||||
loadPeers()
|
||||
})
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peerNodes.value.length > 0
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peerNodes.value = result?.nodes ?? []
|
||||
} catch (e) {
|
||||
if (!hadPeers) peerNodes.value = []
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function peerDisplayName(did: string): string {
|
||||
const suffix = did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
|
||||
return `Node-${suffix}`
|
||||
}
|
||||
|
||||
function openSection(section: ContentSection) {
|
||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||
}
|
||||
|
||||
defineExpose({ loadPeers })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Mobile-only top-level tab switcher — deliberately a DIFFERENT look from the
|
||||
neutral category pills below it (orange tint). Desktop keeps the standard
|
||||
mode-switcher styling. */
|
||||
.cloud-tab-switcher {
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.22);
|
||||
}
|
||||
|
||||
.cloud-tab-btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.cloud-tab-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.cloud-tab-btn-active {
|
||||
background: rgba(247, 147, 26, 0.25);
|
||||
color: #ffd9a8;
|
||||
}
|
||||
|
||||
/* Mobile: the three tabs share the full width equally. */
|
||||
.cloud-tab-switcher-full {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cloud-tab-switcher-full .cloud-tab-btn {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
padding: 0.6rem 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="cloud-folder-container flex flex-col h-full">
|
||||
<!-- Desktop Back Button + Header -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<BackButton :label="backLabel" @click="goBack" />
|
||||
|
||||
<!-- Folder Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section?.iconBg || 'bg-white/10'">
|
||||
<svg class="w-7 h-7" :class="section?.iconColor || 'text-white/70'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in (section?.iconPaths || [])"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<h1 class="text-2xl font-bold text-white">{{ section?.name || 'Folder' }}</h1>
|
||||
<p class="text-sm text-white/50">{{ section?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="appRunning"
|
||||
@click="openExternal"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in New Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Installed -->
|
||||
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ section?.appLabel }} not running</h3>
|
||||
<p class="text-white/60 mb-4">Install {{ section?.appLabel }} from the App Store to manage your {{ section?.name?.toLowerCase() }}.</p>
|
||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
||||
Open App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Store Error -->
|
||||
<div v-else-if="cloudStore.error" class="glass-card p-6 flex-1 flex flex-col items-center justify-center text-center">
|
||||
<div class="alert-error mb-4">{{ cloudStore.error }}</div>
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="cloudStore.refresh()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Native File Browser (for FileBrowser-backed sections) -->
|
||||
<div
|
||||
v-else-if="useNativeUI"
|
||||
class="flex-1 min-h-0 flex flex-col relative"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- Drag-and-drop overlay -->
|
||||
<div v-if="draggingOver" class="cloud-drop-overlay">
|
||||
<div class="cloud-drop-overlay-inner">
|
||||
<svg class="w-12 h-12 text-white/80 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-white/90">Drop files to upload</p>
|
||||
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-white/70">Uploading...</span>
|
||||
</div>
|
||||
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
|
||||
<span class="text-sm text-red-400">{{ uploadError }}</span>
|
||||
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
The transition name flips with navigation direction so descending
|
||||
zooms forward and going back up zooms in reverse — matching the
|
||||
cloud → folder route transition. Only the file content zooms; the
|
||||
header + breadcrumb nav above stay fixed in place. -->
|
||||
<Transition :name="folderTransition" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Audio player is now the global bottom bar (GlobalAudioPlayer in App.vue) -->
|
||||
</div>
|
||||
|
||||
<!-- Fallback iframe (for sections without native UI) -->
|
||||
<div v-else class="flex-1 min-h-0 rounded-xl overflow-hidden border border-white/10">
|
||||
<div v-if="!iframeLoaded" class="flex items-center justify-center h-full">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="w-8 h-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||
<p class="text-sm text-white/60">Loading {{ section?.appLabel }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="appRunning"
|
||||
:src="iframeUrl"
|
||||
class="w-full h-full border-0"
|
||||
:class="{ 'opacity-0': !iframeLoaded }"
|
||||
style="min-height: 500px"
|
||||
@load="iframeLoaded = true"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<ShareModal
|
||||
v-if="shareTarget"
|
||||
:filename="shareTarget.name"
|
||||
:filepath="shareTarget.path"
|
||||
:is-dir="shareTarget.isDir"
|
||||
@close="shareTarget = null"
|
||||
@saved="shareTarget = null"
|
||||
/>
|
||||
|
||||
<!-- Media Lightbox -->
|
||||
<MediaLightbox
|
||||
v-if="lightboxIndex !== null"
|
||||
:items="cloudStore.sortedItems"
|
||||
:start-index="lightboxIndex"
|
||||
:show="lightboxIndex !== null"
|
||||
:fetch-blob-url="cloudStore.fetchBlobUrl"
|
||||
:stream-url="cloudStore.streamUrl"
|
||||
@close="lightboxIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useCloudStore } from '../stores/cloud'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
|
||||
import FileGrid from '../components/cloud/FileGrid.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const cloudStore = useCloudStore()
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Direction-aware folder zoom: descending into a subfolder plays the same
|
||||
// "depth-forward" feel as the cloud → folder route transition (new arrives from
|
||||
// depth, current zooms out toward the viewer); navigating back up plays its
|
||||
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
|
||||
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
|
||||
let prevFolderDepth = -1
|
||||
watch(() => cloudStore.currentPath, (path) => {
|
||||
const depth = path.split('/').filter(Boolean).length
|
||||
// First render (prevFolderDepth === -1) defaults to forward.
|
||||
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
|
||||
prevFolderDepth = depth
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
const packages = store.packages
|
||||
if (packages[appId]?.state === 'running') return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (packages[alias]?.state === 'running') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface ContentSection {
|
||||
name: string
|
||||
description: string
|
||||
appId: string
|
||||
appLabel: string
|
||||
iconPaths: string[]
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
iframeUrl: string
|
||||
externalUrl: string
|
||||
nativeUI: boolean
|
||||
initialPath: string
|
||||
}
|
||||
|
||||
const origin = computed(() => window.location.origin)
|
||||
|
||||
const sections: Record<string, () => ContentSection> = {
|
||||
photos: () => ({
|
||||
name: 'Photos & Videos',
|
||||
description: 'Auto-backup & browse your media',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
iconBg: 'bg-blue-500/15',
|
||||
iconColor: 'text-blue-400',
|
||||
iframeUrl: `${origin.value}/app/immich/photos`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Photos',
|
||||
}),
|
||||
music: () => ({
|
||||
name: 'Music',
|
||||
description: 'Your music collection',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
iconBg: 'bg-orange-500/15',
|
||||
iconColor: 'text-orange-400',
|
||||
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Songs`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Music',
|
||||
}),
|
||||
documents: () => ({
|
||||
name: 'Documents',
|
||||
description: 'Files, docs & spreadsheets',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
iconBg: 'bg-green-500/15',
|
||||
iconColor: 'text-green-400',
|
||||
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Documents`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/Documents',
|
||||
}),
|
||||
files: () => ({
|
||||
name: 'All Files',
|
||||
description: 'Browse your server file system',
|
||||
appId: 'filebrowser',
|
||||
appLabel: 'File Browser',
|
||||
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
||||
iconBg: 'bg-white/10',
|
||||
iconColor: 'text-white/70',
|
||||
iframeUrl: `${origin.value}/app/filebrowser/`,
|
||||
externalUrl: `${origin.value}/app/filebrowser/`,
|
||||
nativeUI: true,
|
||||
initialPath: '/',
|
||||
}),
|
||||
}
|
||||
|
||||
const section = computed(() => {
|
||||
const factory = sections[folderId.value]
|
||||
return factory ? factory() : null
|
||||
})
|
||||
|
||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
||||
// Whether we're at the section's root folder. Derived from the route (the URL
|
||||
// is the source of truth) rather than cloudStore.currentPath, which is async
|
||||
// and still holds the previous/blank path on first entry — that staleness is
|
||||
// what made entering e.g. "Photos and videos" wrongly show "Back to Parent
|
||||
// Folder" and break the back action.
|
||||
const atSectionRoot = computed(() =>
|
||||
!section.value || routeFolderPath.value === section.value.initialPath
|
||||
)
|
||||
const backLabel = computed(() => {
|
||||
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
||||
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
|
||||
})
|
||||
|
||||
// Initialize native file browser when entering a native-UI section
|
||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||
if (native && sec) {
|
||||
if (cloudStore.currentPath !== path) {
|
||||
cloudStore.reset()
|
||||
}
|
||||
const ok = await cloudStore.init()
|
||||
if (ok) {
|
||||
await cloudStore.navigate(path)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
function handlePreview(path: string) {
|
||||
const items = cloudStore.sortedItems
|
||||
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||
const clicked = items.find(item => item.path === path)
|
||||
if (clicked) {
|
||||
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||
void handlePlay(path, clicked.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MediaLightbox internally filters items to media only, so startIndex
|
||||
// must be the index within that filtered list
|
||||
const mediaItems = items.filter(item => {
|
||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||
const cat = getFileCategory(ext, item.isDir)
|
||||
return cat === 'image' || cat === 'video' || cat === 'audio'
|
||||
})
|
||||
const idx = mediaItems.findIndex(item => item.path === path)
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
function handleShare(path: string, name: string, isDir: boolean) {
|
||||
shareTarget.value = { path, name, isDir }
|
||||
}
|
||||
|
||||
const uploadError = ref<string | null>(null)
|
||||
const draggingOver = ref(false)
|
||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onDragOver() {
|
||||
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
|
||||
draggingOver.value = true
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
// Debounce to avoid flicker when dragging between child elements
|
||||
if (dragLeaveTimer) clearTimeout(dragLeaveTimer)
|
||||
dragLeaveTimer = setTimeout(() => { draggingOver.value = false }, 100)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
draggingOver.value = false
|
||||
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
|
||||
const dt = e.dataTransfer
|
||||
if (!dt?.files?.length) return
|
||||
handleUpload(Array.from(dt.files))
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
uploading.value = true
|
||||
uploadError.value = null
|
||||
try {
|
||||
for (const file of files) {
|
||||
await cloudStore.uploadFile(file)
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
await cloudStore.deleteItem(path)
|
||||
}
|
||||
|
||||
async function handlePlay(path: string, name: string) {
|
||||
const url = await cloudStore.streamUrl(path)
|
||||
audioPlayer.play(url, name)
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
if (section.value) {
|
||||
window.open(section.value.externalUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateCloudPath(path: string) {
|
||||
const target = normalizeCloudPath(path, section.value?.initialPath || '/')
|
||||
await router.push({
|
||||
name: 'cloud-folder',
|
||||
params: { folderId: folderId.value },
|
||||
query: target === section.value?.initialPath ? {} : { path: target },
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (useNativeUI.value && !atSectionRoot.value) {
|
||||
navigateCloudPath(parentCloudPath(routeFolderPath.value))
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Mirrors the
|
||||
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
|
||||
blur) so descending into a folder and going back up feel identical to the
|
||||
cloud ⇄ folder route change. -->
|
||||
<style>
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition:
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* Forward (into a deeper folder): new folder arrives from depth while the
|
||||
current one zooms out toward the viewer — matches depth-forward. */
|
||||
.cloud-zoom-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
.cloud-zoom-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back (up to a parent folder): the mirror — new folder shrinks in from the
|
||||
front while the current one recedes into depth — matches depth-back. */
|
||||
.cloud-zoom-back-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.cloud-zoom-back-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-forward-enter-from,
|
||||
.cloud-zoom-forward-leave-to,
|
||||
.cloud-zoom-back-enter-from,
|
||||
.cloud-zoom-back-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<BackButton :label="t('containerDetails.back')" @click="$router.back()" />
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as ContainerStateValue"
|
||||
:health="healthStatus as HealthStatusValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<div v-if="loading" key="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" key="error" class="glass-card p-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="container" key="content" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<!-- Single primary button: Start when stopped, Stop when running,
|
||||
transitional label + spinner while stopping/starting/restarting. -->
|
||||
<button
|
||||
:disabled="isPrimaryDisabled"
|
||||
@click="handlePrimary"
|
||||
:class="primaryButtonClass"
|
||||
class="px-6 py-3 rounded-lg font-medium text-white transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<svg v-if="isTransitional" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ primaryButtonLabel }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || isTransitional || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading || isTransitional"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
{{ t('containerDetails.loadingLogs') }}
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
{{ t('containerDetails.noLogs') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
type ContainerStateValue =
|
||||
| 'created'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'exited'
|
||||
| 'paused'
|
||||
| 'unknown'
|
||||
| 'stopping'
|
||||
| 'starting'
|
||||
| 'restarting'
|
||||
| 'installing'
|
||||
| 'updating'
|
||||
| 'removing'
|
||||
| 'installed'
|
||||
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
return appId.value
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<ContainerStatusData | null>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const healthStatus = ref<string>('unknown')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
|
||||
async function loadContainer() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthStatus() {
|
||||
await store.fetchHealthStatus()
|
||||
healthStatus.value = store.getHealthStatus(appId.value)
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
// Use the async container-restart RPC (returns immediately, backend
|
||||
// flips state to Restarting and spawns the stop+start sequence).
|
||||
await store.restartContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Single-button state helpers mirroring ContainerApps.vue — driven off the
|
||||
// backend state so transitional labels stay accurate across the 5–600s
|
||||
// graceful-stop window.
|
||||
const isTransitional = computed(() => {
|
||||
const s = container.value?.state
|
||||
return (
|
||||
s === 'stopping' ||
|
||||
s === 'starting' ||
|
||||
s === 'restarting' ||
|
||||
s === 'installing' ||
|
||||
s === 'updating' ||
|
||||
s === 'removing'
|
||||
)
|
||||
})
|
||||
|
||||
const isPrimaryDisabled = computed(() => actionLoading.value || isTransitional.value)
|
||||
|
||||
const primaryButtonLabel = computed(() => {
|
||||
const s = container.value?.state
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return t('containerDetails.stopContainer')
|
||||
case 'stopping':
|
||||
return 'Stopping…'
|
||||
case 'starting':
|
||||
return 'Starting…'
|
||||
case 'restarting':
|
||||
return 'Restarting…'
|
||||
case 'installing':
|
||||
return 'Installing…'
|
||||
case 'updating':
|
||||
return 'Updating…'
|
||||
case 'removing':
|
||||
return 'Removing…'
|
||||
default:
|
||||
return t('containerDetails.startContainer')
|
||||
}
|
||||
})
|
||||
|
||||
const primaryButtonClass = computed(() => {
|
||||
const s = container.value?.state
|
||||
if (s === 'running') {
|
||||
return 'glass-button hover:text-white text-white/90'
|
||||
}
|
||||
if (isTransitional.value) {
|
||||
return 'bg-yellow-700/40 text-yellow-200'
|
||||
}
|
||||
return 'bg-green-600 hover:bg-green-500'
|
||||
})
|
||||
|
||||
async function handlePrimary() {
|
||||
const s = container.value?.state
|
||||
if (s === 'running') {
|
||||
return handleStop()
|
||||
}
|
||||
if (!isTransitional.value) {
|
||||
return handleStart()
|
||||
}
|
||||
}
|
||||
|
||||
// Poll every 2s while a transition is in flight so the label updates
|
||||
// without needing a manual refresh.
|
||||
let transitionalPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
transitionalPollInterval = setInterval(async () => {
|
||||
if (isTransitional.value) {
|
||||
try {
|
||||
await loadContainer()
|
||||
} catch {
|
||||
// ignore transient poll errors
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (transitionalPollInterval) clearInterval(transitionalPollInterval)
|
||||
})
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/apps')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
|
||||
<p class="text-white/70">Manage your Archipelago applications</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State (initial load) -->
|
||||
<div v-if="store.loading && !hasAnyApps" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="store.error" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Bundled Apps -->
|
||||
<div
|
||||
v-for="app in bundledApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
|
||||
tabindex="0"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl">{{ app.icon }}</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{{ app.name }}</h3>
|
||||
<p class="text-sm text-white/60">{{ app.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="mb-4">
|
||||
<span
|
||||
:class="getStatusBadgeClass(app.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
>
|
||||
<!-- Loading spinner -->
|
||||
<svg
|
||||
v-if="store.isAppLoading(app.id)"
|
||||
class="w-3 h-3 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
v-else
|
||||
:class="getStatusDotClass(app.id)"
|
||||
class="w-2 h-2 rounded-full"
|
||||
></span>
|
||||
{{ getStatusText(app.id) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Port info -->
|
||||
<div class="text-sm text-white/50 mb-4">
|
||||
<span v-if="store.getAppState(app.id) === 'running'">
|
||||
Port{{ app.ports.length > 1 ? 's' : '' }}:
|
||||
{{ app.ports.map(p => p.host).join(', ') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ app.image.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Single primary button whose label + action depend on visual state.
|
||||
Transitional states (stopping/starting/restarting/installing/
|
||||
updating/removing) show a spinner and are disabled. -->
|
||||
<button
|
||||
:disabled="isPrimaryDisabled(app.id)"
|
||||
@click="handlePrimary(app)"
|
||||
:class="primaryButtonClass(app.id)"
|
||||
class="flex-1 px-4 py-2 rounded text-sm font-medium text-white transition-colors flex items-center justify-center gap-2 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="isTransitional(app.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ primaryButtonLabel(app.id) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Restart button: only visible when running, hidden during transitions -->
|
||||
<button
|
||||
v-if="store.getAppVisualState(app.id) === 'running'"
|
||||
@click="handleRestartApp(app.id)"
|
||||
:disabled="store.isAppLoading(app.id)"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white disabled:cursor-not-allowed transition-colors"
|
||||
:title="'Restart ' + app.name"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Launch button: only when running -->
|
||||
<button
|
||||
v-if="store.getAppVisualState(app.id) === 'running'"
|
||||
type="button"
|
||||
data-controller-launch-btn
|
||||
class="px-4 py-2 glass-button glass-button-warning rounded text-sm font-medium flex items-center gap-2"
|
||||
@click="launchApp(app)"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other containers (not bundled) -->
|
||||
<div
|
||||
v-for="container in otherContainers"
|
||||
:key="container.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl">📦</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{{ extractAppName(container.name) }}</h3>
|
||||
<p class="text-sm text-white/60">{{ container.image }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<ContainerStatus :state="container.state" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click="handleStartContainer(container.name)"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleStopContainer(container.name)"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (when no bundled apps - shouldn't happen) -->
|
||||
<div v-if="!hasAnyApps && !store.loading" class="glass-card p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No apps available</h3>
|
||||
<p class="text-white/60">Check your Archipelago installation</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useContainerStore, type BundledApp } from '@/stores/container'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const store = useContainerStore()
|
||||
const appLauncherStore = useAppLauncherStore()
|
||||
|
||||
// Use enriched bundled apps with runtime data (like lan_address)
|
||||
// Only show apps that actually have a container (hides pre-defined apps on unbundled installs)
|
||||
const bundledApps = computed(() => store.enrichedBundledApps.filter(
|
||||
app => store.getAppState(app.id) !== 'not-installed'
|
||||
))
|
||||
|
||||
// Get current host for launch URLs
|
||||
const currentHost = computed(() => window.location.hostname)
|
||||
|
||||
let startingPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
// Refresh every 10 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// When any bundled app is transitional (stopping/starting/restarting/etc),
|
||||
// poll every 2s so state updates flow through quickly.
|
||||
startingPollInterval = setInterval(async () => {
|
||||
const anyTransitional = bundledApps.value.some((app) => isTransitional(app.id))
|
||||
if (anyTransitional) {
|
||||
try {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (startingPollInterval) clearInterval(startingPollInterval)
|
||||
})
|
||||
|
||||
// Containers that aren't bundled apps
|
||||
const otherContainers = computed(() => {
|
||||
const bundledIds = bundledApps.value.map(a => a.id)
|
||||
return store.containers.filter(c => {
|
||||
const name = c.name.toLowerCase()
|
||||
return !bundledIds.some(id => name.includes(id))
|
||||
})
|
||||
})
|
||||
|
||||
const hasAnyApps = computed(() => bundledApps.value.length > 0 || store.containers.length > 0)
|
||||
|
||||
function extractAppName(containerName: string): string {
|
||||
return containerName
|
||||
.replace('archipelago-', '')
|
||||
.replace('-dev', '')
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'bg-green-500/20 text-green-400'
|
||||
case 'stopped':
|
||||
return 'bg-gray-500/20 text-gray-400'
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
case 'restarting':
|
||||
case 'installing':
|
||||
case 'updating':
|
||||
case 'removing':
|
||||
return 'bg-yellow-500/20 text-yellow-400'
|
||||
case 'not-installed':
|
||||
default:
|
||||
return 'bg-blue-500/20 text-blue-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDotClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'bg-green-400'
|
||||
case 'stopped':
|
||||
return 'bg-gray-400'
|
||||
case 'not-installed':
|
||||
return 'bg-blue-400'
|
||||
default:
|
||||
return 'bg-yellow-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'stopped':
|
||||
return 'Stopped'
|
||||
case 'not-installed':
|
||||
return 'Ready to Start'
|
||||
case 'starting':
|
||||
return 'Starting'
|
||||
case 'stopping':
|
||||
return 'Stopping'
|
||||
case 'restarting':
|
||||
return 'Restarting'
|
||||
case 'installing':
|
||||
return 'Installing'
|
||||
case 'updating':
|
||||
return 'Updating'
|
||||
case 'removing':
|
||||
return 'Removing'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function isTransitional(appId: string): boolean {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
return (
|
||||
vs === 'starting' ||
|
||||
vs === 'stopping' ||
|
||||
vs === 'restarting' ||
|
||||
vs === 'installing' ||
|
||||
vs === 'updating' ||
|
||||
vs === 'removing'
|
||||
)
|
||||
}
|
||||
|
||||
function isPrimaryDisabled(appId: string): boolean {
|
||||
return isTransitional(appId) || store.isAppLoading(appId)
|
||||
}
|
||||
|
||||
function primaryButtonLabel(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
switch (vs) {
|
||||
case 'running':
|
||||
return 'Stop'
|
||||
case 'stopped':
|
||||
case 'not-installed':
|
||||
return 'Start'
|
||||
case 'starting':
|
||||
return 'Starting…'
|
||||
case 'stopping':
|
||||
return 'Stopping…'
|
||||
case 'restarting':
|
||||
return 'Restarting…'
|
||||
case 'installing':
|
||||
return 'Installing…'
|
||||
case 'updating':
|
||||
return 'Updating…'
|
||||
case 'removing':
|
||||
return 'Removing…'
|
||||
default:
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function primaryButtonClass(appId: string): string {
|
||||
const vs = store.getAppVisualState(appId)
|
||||
if (vs === 'running') {
|
||||
return 'glass-button hover:text-white text-white/90 disabled:opacity-60'
|
||||
}
|
||||
if (vs === 'stopped' || vs === 'not-installed') {
|
||||
return 'bg-green-600 hover:bg-green-500 disabled:bg-green-800'
|
||||
}
|
||||
// Transitional: muted appearance
|
||||
return 'bg-yellow-700/40 text-yellow-200 disabled:opacity-80'
|
||||
}
|
||||
|
||||
const backendPort = 5678
|
||||
|
||||
function getLaunchUrl(app: BundledApp): string {
|
||||
// Prefer lan_address from backend (for apps with custom UIs)
|
||||
if (app.lan_address) {
|
||||
// Replace localhost so Launch works when browsing from another machine (e.g. 192.168.1.228)
|
||||
let url = app.lan_address.replace(/localhost/i, currentHost.value)
|
||||
// LND UI (and other app UIs) need backend URL for live data (logs, getinfo proxy)
|
||||
if (app.id === 'lnd') {
|
||||
const backend = `http://${currentHost.value}:${backendPort}`
|
||||
url += (url.includes('?') ? '&' : '?') + 'backend=' + encodeURIComponent(backend)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// Fallback to first configured port
|
||||
const port = app.ports[0]?.host
|
||||
if (!port) return '#'
|
||||
return `http://${currentHost.value}:${port}`
|
||||
}
|
||||
|
||||
function launchApp(app: BundledApp) {
|
||||
const url = getLaunchUrl(app)
|
||||
if (url === '#') return
|
||||
appLauncherStore.open({ url, title: app.name })
|
||||
}
|
||||
|
||||
async function handlePrimary(app: BundledApp) {
|
||||
const vs = store.getAppVisualState(app.id)
|
||||
if (vs === 'running') {
|
||||
return handleStopApp(app.id)
|
||||
}
|
||||
if (vs === 'stopped' || vs === 'not-installed') {
|
||||
return handleStartApp(app)
|
||||
}
|
||||
// Transitional — button should be disabled; ignore.
|
||||
}
|
||||
|
||||
async function handleStartApp(app: BundledApp) {
|
||||
try {
|
||||
// Route through container-start (async) rather than the legacy synchronous
|
||||
// bundled-app-start RPC so Stop/Start exercise the spawn_transitional path
|
||||
// and the UI sees Starting → Running transitions.
|
||||
await store.startContainer(app.id)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopApp(appId: string) {
|
||||
try {
|
||||
await store.stopContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestartApp(appId: string) {
|
||||
try {
|
||||
await store.restartContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to restart app:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartContainer(name: string) {
|
||||
try {
|
||||
const appId = name.replace('archipelago-', '').replace('-dev', '')
|
||||
await store.startContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start container:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopContainer(name: string) {
|
||||
try {
|
||||
const appId = name.replace('archipelago-', '').replace('-dev', '')
|
||||
await store.stopContainer(appId)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop container:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton label="Back to Web5" @click="$router.push('/dashboard/web5')" />
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Credentials</h1>
|
||||
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Issue Credential Form -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Issue New Credential</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Issuer Identity <span class="text-red-400">*</span></label>
|
||||
<select v-model="issueForm.issuerId" class="credential-input w-full">
|
||||
<option value="" disabled>Select identity</option>
|
||||
<option v-for="id in identities" :key="id.id" :value="id.id">
|
||||
{{ id.name }} ({{ id.did?.slice(0, 24) }}...)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Credential Type</label>
|
||||
<input v-model="issueForm.type" type="text" placeholder="e.g. NodeOperator" class="credential-input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Subject DID <span class="text-red-400">*</span></label>
|
||||
<input v-model="issueForm.subjectDid" type="text" placeholder="did:key:z6Mk..." class="credential-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Claims (JSON)</label>
|
||||
<textarea v-model="issueForm.claimsJson" rows="3" placeholder='{"role": "admin", "level": "full"}' class="credential-input w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Expiration (optional)</label>
|
||||
<input v-model="issueForm.expiresAt" type="datetime-local" class="credential-input w-full" />
|
||||
</div>
|
||||
<button @click="issueCredential" :disabled="issuing || !issueForm.issuerId || !issueForm.subjectDid" class="glass-button px-6 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ issuing ? 'Issuing...' : 'Issue Credential' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credentials List -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Your Credentials</h2>
|
||||
<button @click="loadCredentials" :disabled="loadingCreds" class="glass-button glass-button-sm px-3 py-1.5 text-xs">
|
||||
{{ loadingCreds ? 'Loading...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingCreds && credentials.length === 0" class="text-white/50 text-sm py-8 text-center">
|
||||
Loading credentials...
|
||||
</div>
|
||||
<div v-else-if="credentials.length === 0" class="text-white/50 text-sm py-8 text-center">
|
||||
No credentials yet. Issue one above or receive one from a peer.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="loadingCreds" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing credentials...
|
||||
</div>
|
||||
<div
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
class="bg-black/20 rounded-xl border border-white/10 p-4 cursor-pointer hover:border-white/20 transition-colors"
|
||||
@click="selectedCredential = cred"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ credentialTypeLabel(cred) }}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="cred.status === 'revoked' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'"
|
||||
>
|
||||
{{ cred.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-white/40 font-mono">{{ cred.id?.slice(0, 20) }}...</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/50 space-y-0.5">
|
||||
<p>Issuer: <span class="font-mono">{{ cred.issuer?.slice(0, 32) }}...</span></p>
|
||||
<p>Subject: <span class="font-mono">{{ cred.credentialSubject?.id?.slice(0, 32) }}...</span></p>
|
||||
<p>Issued: {{ formatDate(cred.issuanceDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verify Credential -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Verify Credential</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/70 mb-1">Credential ID</label>
|
||||
<input v-model="verifyId" type="text" placeholder="urn:uuid:..." class="credential-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="verifyCredential" :disabled="verifying || !verifyId" class="glass-button px-6 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ verifying ? 'Verifying...' : 'Verify' }}
|
||||
</button>
|
||||
<div v-if="verifyResult !== null" class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" :class="verifyResult ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="verifyResult ? 'text-green-400' : 'text-red-400'">
|
||||
{{ verifyResult ? 'Valid' : 'Invalid' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credential Detail Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||
<button @click="selectedCredential = null" class="text-white/60 hover:text-white text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Type</p>
|
||||
<p class="text-white font-medium">{{ credentialTypeLabel(selectedCredential) }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">ID</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.id }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Issuer</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.issuer }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Subject</p>
|
||||
<p class="text-white/80 font-mono text-xs break-all">{{ selectedCredential.credentialSubject?.id }}</p>
|
||||
</div>
|
||||
<div v-if="selectedCredential.credentialSubject" class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Claims</p>
|
||||
<pre class="text-white/80 font-mono text-xs whitespace-pre-wrap">{{ formatClaims(selectedCredential.credentialSubject) }}</pre>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Issued</p>
|
||||
<p class="text-white/80 text-xs">{{ formatDate(selectedCredential.issuanceDate) }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Expires</p>
|
||||
<p class="text-white/80 text-xs">{{ selectedCredential.expirationDate ? formatDate(selectedCredential.expirationDate) : 'Never' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Proof</p>
|
||||
<p class="text-white/80 text-xs">{{ selectedCredential.proof?.type }} — {{ selectedCredential.proof?.proofPurpose }}</p>
|
||||
<p class="text-white/60 font-mono text-xs mt-1 break-all">{{ selectedCredential.proof?.proofValue }}</p>
|
||||
</div>
|
||||
<div class="bg-black/20 rounded-lg p-3">
|
||||
<p class="text-white/50 text-xs mb-1">Status</p>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="selectedCredential.status === 'revoked' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'"
|
||||
>
|
||||
{{ selectedCredential.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="copyCredentialJson" class="glass-button px-4 py-2 text-sm">
|
||||
{{ credCopied ? 'Copied!' : 'Copy JSON' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedCredential.status !== 'revoked'"
|
||||
@click="revokeSelected"
|
||||
:disabled="revoking"
|
||||
class="glass-button px-4 py-2 text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
{{ revoking ? 'Revoking...' : 'Revoke' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="toast" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 px-6 py-3 rounded-xl text-sm font-medium shadow-lg"
|
||||
:class="toast.type === 'error' ? 'bg-red-500/90 text-white' : 'bg-green-500/90 text-white'"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface Identity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
interface Credential {
|
||||
'@context'?: string[]
|
||||
id: string
|
||||
type?: string[]
|
||||
issuer: string
|
||||
credentialSubject?: { id: string; [key: string]: unknown }
|
||||
issuanceDate?: string
|
||||
expirationDate?: string | null
|
||||
proof?: {
|
||||
type: string
|
||||
created: string
|
||||
verificationMethod: string
|
||||
proofPurpose: string
|
||||
proofValue: string
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
const identities = ref<Identity[]>([])
|
||||
const credentials = ref<Credential[]>([])
|
||||
const loadingCreds = ref(false)
|
||||
const selectedCredential = ref<Credential | null>(null)
|
||||
const credCopied = ref(false)
|
||||
const revoking = ref(false)
|
||||
|
||||
// Issue form
|
||||
const issueForm = ref({
|
||||
issuerId: '',
|
||||
subjectDid: '',
|
||||
type: 'NodeOperator',
|
||||
claimsJson: '{}',
|
||||
expiresAt: '',
|
||||
})
|
||||
const issuing = ref(false)
|
||||
|
||||
// Verify
|
||||
const verifyId = ref('')
|
||||
const verifying = ref(false)
|
||||
const verifyResult = ref<boolean | null>(null)
|
||||
|
||||
// Toast
|
||||
const toast = ref<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
||||
toast.value = { message, type }
|
||||
setTimeout(() => { toast.value = null }, 3000)
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string | null): string {
|
||||
if (!dateStr) return 'N/A'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function credentialTypeLabel(cred: Credential): string {
|
||||
if (!cred.type || cred.type.length === 0) return 'Credential'
|
||||
// Return the most specific type (last non-VerifiableCredential type)
|
||||
const specific = cred.type.filter((t: string) => t !== 'VerifiableCredential')
|
||||
return specific.length > 0 ? (specific[specific.length - 1] ?? 'Credential') : 'VerifiableCredential'
|
||||
}
|
||||
|
||||
function formatClaims(subject: Record<string, unknown>): string {
|
||||
const claims = { ...subject }
|
||||
delete claims.id
|
||||
return JSON.stringify(claims, null, 2)
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
try {
|
||||
const result = await rpcClient.call<{ identities: Identity[] }>({
|
||||
method: 'identity.list',
|
||||
params: {},
|
||||
})
|
||||
identities.value = result.identities || []
|
||||
} catch (e) {
|
||||
identities.value = []
|
||||
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
loadingCreds.value = true
|
||||
try {
|
||||
const result = await rpcClient.call<{ credentials: Credential[] }>({
|
||||
method: 'identity.list-credentials',
|
||||
params: {},
|
||||
})
|
||||
credentials.value = result.credentials || []
|
||||
} catch (e) {
|
||||
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
loadingCreds.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function issueCredential() {
|
||||
if (!issueForm.value.issuerId || !issueForm.value.subjectDid) {
|
||||
showToast('Issuer identity and subject DID are required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
let claims: Record<string, unknown>
|
||||
try {
|
||||
claims = JSON.parse(issueForm.value.claimsJson)
|
||||
} catch {
|
||||
showToast('Invalid JSON in claims field', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
issuing.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.issue-credential',
|
||||
params: {
|
||||
issuer_id: issueForm.value.issuerId,
|
||||
subject_did: issueForm.value.subjectDid,
|
||||
type: issueForm.value.type || 'VerifiableCredential',
|
||||
claims,
|
||||
expires_at: issueForm.value.expiresAt || undefined,
|
||||
},
|
||||
})
|
||||
showToast('Credential issued successfully')
|
||||
issueForm.value.subjectDid = ''
|
||||
issueForm.value.claimsJson = '{}'
|
||||
issueForm.value.expiresAt = ''
|
||||
await loadCredentials()
|
||||
} catch (e) {
|
||||
showToast(`Failed to issue credential: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
issuing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCredential() {
|
||||
if (!verifyId.value) {
|
||||
showToast('Enter a credential ID to verify', 'error')
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
verifyResult.value = null
|
||||
try {
|
||||
const result = await rpcClient.call<{ valid: boolean }>({
|
||||
method: 'identity.verify-credential',
|
||||
params: { id: verifyId.value },
|
||||
})
|
||||
verifyResult.value = result.valid
|
||||
} catch (e) {
|
||||
showToast(`Verification failed: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
verifyResult.value = false
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSelected() {
|
||||
if (!selectedCredential.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.revoke-credential',
|
||||
params: { id: selectedCredential.value.id },
|
||||
})
|
||||
showToast('Credential revoked')
|
||||
selectedCredential.value = null
|
||||
await loadCredentials()
|
||||
} catch (e) {
|
||||
showToast(`Failed to revoke: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCredentialJson() {
|
||||
if (!selectedCredential.value) return
|
||||
const json = JSON.stringify(selectedCredential.value, null, 2)
|
||||
try {
|
||||
await navigator.clipboard.writeText(json)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = json
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
credCopied.value = true
|
||||
setTimeout(() => { credCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadIdentities(), loadCredentials()])
|
||||
})
|
||||
|
||||
defineExpose({ credentials, loadCredentials })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.credential-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.credential-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
select.credential-input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='rgba(255,255,255,0.5)' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
select.credential-input option {
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
}
|
||||
textarea.credential-input {
|
||||
resize: vertical;
|
||||
min-height: 4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Skip to main content link for keyboard users -->
|
||||
<!-- Skip-to-content handled by controller nav sidebar→main transition -->
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||
<div
|
||||
ref="bgDefault"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="[
|
||||
{ 'bg-transitioning-out': showAltBackground },
|
||||
{ 'zoom-reveal-bg': showZoomIn }
|
||||
]"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<!-- Background - detail layer (only visible during app/marketplace detail 3D transition) -->
|
||||
<div
|
||||
ref="bgAlt"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="{ 'bg-transitioning-in': showAltBackground }"
|
||||
style="background-image: url(/assets/img/bg-intro-3.jpg)"
|
||||
/>
|
||||
<!-- Glitch overlays - trigger on background change -->
|
||||
<div
|
||||
class="bg-glitch-layer-1"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<div
|
||||
class="bg-glitch-layer-2"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
||||
/>
|
||||
<div
|
||||
class="bg-glitch-scan"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
/>
|
||||
<!-- Glitch overlays removed — only intro glitch plays (via isGlitching) -->
|
||||
</div>
|
||||
|
||||
<!-- Oomph accent - brief impact flash when dashboard loads -->
|
||||
<div
|
||||
v-if="showZoomIn"
|
||||
class="fixed inset-0 pointer-events-none z-[100] oomph-flash"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Reveal flashes and glitch overlay - enthralling entrance -->
|
||||
<div
|
||||
v-if="showZoomIn"
|
||||
class="fixed inset-0 pointer-events-none z-[99] reveal-flash-glitch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Background overlay. The web5/server backdrop is a much lighter image
|
||||
than the others, so it gets a heavier scrim for text contrast. -->
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none bg-black transition-opacity duration-500"
|
||||
:class="isWeb5Bg ? 'opacity-[0.45]' : 'opacity-20'"
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
<!-- Sidebar - Desktop Only -->
|
||||
<DashboardSidebar :show-zoom-in="showZoomIn" @logout="handleLogout" />
|
||||
|
||||
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
||||
<main
|
||||
id="main-content"
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
@touchstart.passive="onContentTouchStart"
|
||||
@touchend.passive="onContentTouchEnd"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Banners -->
|
||||
<ConnectionBanner />
|
||||
|
||||
<div class="perspective-container-wrapper glass-piece" :class="{ 'glass-throw-content': showZoomIn && !isHomeRoute }">
|
||||
<div class="perspective-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
:class="[
|
||||
'h-full',
|
||||
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
|
||||
mobileTabPaddingTop ? 'overflow-y-auto' : ''
|
||||
]"
|
||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||
class="mobile-safe-top"
|
||||
>
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
|
||||
needsMobileBackButtonSpace
|
||||
? 'mobile-scroll-pad-back'
|
||||
: 'mobile-scroll-pad'
|
||||
]"
|
||||
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
|
||||
>
|
||||
<component :is="Component" class="view-container flex-none" />
|
||||
<!-- Bottom spacer — scroll clearance on all pages -->
|
||||
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
||||
<AppSession
|
||||
:app-id-prop="appLauncher.panelAppId"
|
||||
:path-prop="appLauncher.panelPath ?? undefined"
|
||||
@close="appLauncher.closePanel()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
||||
<DashboardMobileNav ref="mobileNavRef" :show-zoom-in="showZoomIn" />
|
||||
|
||||
<!-- Health Notifications Toast -->
|
||||
<HealthNotifications />
|
||||
|
||||
<!-- First-use companion intro overlay -->
|
||||
<CompanionIntroOverlay />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||
|
||||
import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
||||
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
||||
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
|
||||
useIbdFinishWatcher()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const { getTransitionName } = useRouteTransitions()
|
||||
|
||||
const showZoomIn = ref(false)
|
||||
const pendingTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function scheduledTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(fn, ms)
|
||||
pendingTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const showAltBackground = ref(false)
|
||||
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
||||
const isGlitching = ref(false)
|
||||
|
||||
const backgroundImage = computed(() => {
|
||||
const mapped = ROUTE_BACKGROUNDS[route.path]
|
||||
if (mapped) return mapped
|
||||
// Detail/sub pages inherit their parent tab's background so they stay
|
||||
// visually "inside" the section instead of snapping to the home backdrop.
|
||||
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
|
||||
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
||||
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
|
||||
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
||||
return 'bg-home.webp'
|
||||
})
|
||||
|
||||
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens
|
||||
// while it's showing so light text keeps its contrast.
|
||||
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
||||
|
||||
const isDarkRoute = computed(() => {
|
||||
const p = route.path
|
||||
return p.includes('/dashboard/web5') ||
|
||||
p.includes('/dashboard/server') ||
|
||||
p.includes('/dashboard/settings') ||
|
||||
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
|
||||
p.includes('/dashboard/marketplace') ||
|
||||
p.includes('/dashboard/discover') ||
|
||||
p.includes('/dashboard/cloud')
|
||||
})
|
||||
|
||||
const showDarkOverlay = ref(isDarkRoute.value)
|
||||
let overlayTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(isDarkRoute, (dark) => {
|
||||
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
||||
if (dark) {
|
||||
showDarkOverlay.value = true
|
||||
} else {
|
||||
overlayTimer = scheduledTimeout(() => { showDarkOverlay.value = false }, 450)
|
||||
}
|
||||
})
|
||||
|
||||
const WEB5_DETAIL_ROUTES = ['/dashboard/server/federation', '/dashboard/monitoring', '/dashboard/fleet']
|
||||
const needsMobileBackButtonSpace = computed(() =>
|
||||
isDetailRoute(route.path) || WEB5_DETAIL_ROUTES.includes(route.path)
|
||||
)
|
||||
|
||||
const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null)
|
||||
|
||||
const mobileTabPaddingTop = computed(() => {
|
||||
return mobileNavRef.value?.mobileTabPaddingTop ?? 0
|
||||
})
|
||||
|
||||
// Scroll position save/restore — only restore when coming BACK from a detail page
|
||||
const savedScrollPositions = new Map<string, number>()
|
||||
let previousRoutePath = ''
|
||||
|
||||
function isAnyDetailRoute(path: string): boolean {
|
||||
return isDetailRoute(path) || WEB5_DETAIL_ROUTES.includes(path)
|
||||
}
|
||||
|
||||
function saveCurrentScroll() {
|
||||
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
|
||||
if (el && previousRoutePath) {
|
||||
savedScrollPositions.set(previousRoutePath, el.scrollTop)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreScroll(path: string) {
|
||||
const saved = savedScrollPositions.get(path)
|
||||
if (saved == null) return
|
||||
nextTick(() => {
|
||||
// Wait for transition to settle
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
|
||||
if (el) el.scrollTop = saved
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
function activateMainScroll() {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
|
||||
active.blur()
|
||||
document.getElementById('main-content')?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipe left/right to move between the mobile top tabs ──────────────────
|
||||
// Apps screen: My Apps ⇄ App Store ⇄ Services
|
||||
// Web5 screen: Web5 ⇄ Cloud ⇄ Network ⇄ Mesh
|
||||
// Only active while the matching tab strip is actually showing (mobile).
|
||||
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
|
||||
const APPS_TABS: TabTarget[] = [
|
||||
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
|
||||
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
|
||||
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
|
||||
]
|
||||
const NET_TABS: TabTarget[] = [
|
||||
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
|
||||
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
|
||||
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
|
||||
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
|
||||
]
|
||||
|
||||
function activeAppsKey(): string {
|
||||
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
|
||||
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
|
||||
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
|
||||
return ''
|
||||
}
|
||||
function activeNetKey(): string {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard/web5')) return 'web5'
|
||||
if (p.startsWith('/dashboard/cloud')) return 'cloud'
|
||||
if (p.startsWith('/dashboard/server')) return 'server'
|
||||
if (p.startsWith('/dashboard/mesh')) return 'mesh'
|
||||
return ''
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
let swipeSuppressed = false
|
||||
function onContentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
// Don't begin a tab swipe when the gesture starts on an app icon (let the icon
|
||||
// handle tap/long-press) or on a horizontally-scrollable category strip (let
|
||||
// it scroll its own chips). Swiping anywhere else still changes tabs.
|
||||
swipeSuppressed = !!(
|
||||
e.target instanceof Element &&
|
||||
e.target.closest('.app-icon-item, .mobile-category-strip')
|
||||
)
|
||||
touchStartX = t.clientX
|
||||
touchStartY = t.clientY
|
||||
touchStartTime = e.timeStamp
|
||||
}
|
||||
function onContentTouchEnd(e: TouchEvent) {
|
||||
if (swipeSuppressed) { swipeSuppressed = false; return }
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
const dx = t.clientX - touchStartX
|
||||
const dy = t.clientY - touchStartY
|
||||
const dt = e.timeStamp - touchStartTime
|
||||
// Clear horizontal flick: far enough, mostly sideways, and quick.
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
|
||||
|
||||
const nav = mobileNavRef.value
|
||||
if (!nav) return
|
||||
let tabs: TabTarget[] | null = null
|
||||
let activeKey = ''
|
||||
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
|
||||
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
|
||||
if (!tabs) return
|
||||
|
||||
const idx = tabs.findIndex(tb => tb.key === activeKey)
|
||||
if (idx < 0) return
|
||||
const next = idx + (dx < 0 ? 1 : -1) // swipe left → next tab, right → previous
|
||||
if (next < 0 || next >= tabs.length) return
|
||||
const target = tabs[next]
|
||||
if (!target) return
|
||||
router.push(target.to).catch(() => {})
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
const oldPath = previousRoutePath
|
||||
const wasDetail = isAnyDetailRoute(oldPath)
|
||||
const isDetail = isAnyDetailRoute(newPath)
|
||||
|
||||
// Save scroll position of the page we're leaving
|
||||
saveCurrentScroll()
|
||||
|
||||
// Restore scroll only when returning from a detail page to the parent list
|
||||
if (wasDetail && !isDetail) {
|
||||
restoreScroll(newPath)
|
||||
}
|
||||
|
||||
previousRoutePath = newPath
|
||||
|
||||
showAltBackground.value = isAppDetails
|
||||
|
||||
if (isAppDetails && !wasAppDetails) {
|
||||
scheduledTimeout(() => {
|
||||
isGlitching.value = true
|
||||
scheduledTimeout(() => { isGlitching.value = false }, 375)
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
previousRoutePath = route.path
|
||||
document.body.classList.add('dashboard-active')
|
||||
if (loginTransition.justCompletedOnboarding) {
|
||||
// Full glitchy reveal — only on the very first dashboard entry
|
||||
// right after onboarding (one-time event, persists in feel).
|
||||
// force=true: onboarding is already marked complete by now, so the
|
||||
// un-forced call would gate itself silent.
|
||||
playDashboardLoadOomph(true)
|
||||
showZoomIn.value = true
|
||||
loginTransition.setIntroCinematicPlaying(true)
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustCompletedOnboarding(false)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
const triggerRevealGlitch = () => {
|
||||
isGlitching.value = true
|
||||
scheduledTimeout(() => { isGlitching.value = false }, 380)
|
||||
}
|
||||
scheduledTimeout(triggerRevealGlitch, 500)
|
||||
scheduledTimeout(triggerRevealGlitch, 1200)
|
||||
scheduledTimeout(triggerRevealGlitch, 2000)
|
||||
scheduledTimeout(() => {
|
||||
showZoomIn.value = false
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
}, 8000)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 4000)
|
||||
} else if (loginTransition.justLoggedIn) {
|
||||
// Regular re-login — no zoom, no glitch. Just land on the
|
||||
// dashboard and kick off the welcome typing quickly.
|
||||
playDashboardLoadOomph()
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
scheduledTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKioskShortcuts)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-active')
|
||||
// Timers are cleared below; make sure overlays waiting on the cinematic
|
||||
// aren't left blocked forever if we unmount mid-reveal.
|
||||
loginTransition.setIntroCinematicPlaying(false)
|
||||
window.removeEventListener('keydown', handleKioskShortcuts)
|
||||
for (const id of pendingTimers) clearTimeout(id)
|
||||
pendingTimers.length = 0
|
||||
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
||||
})
|
||||
|
||||
function isKioskMode(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function handleKioskShortcuts(e: KeyboardEvent) {
|
||||
if (!isKioskMode()) return
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
if (e.key === 'R' || e.key === 'r') {
|
||||
e.preventDefault()
|
||||
router.push('/recovery')
|
||||
} else if (e.key === 'H' || e.key === 'h') {
|
||||
e.preventDefault()
|
||||
router.push('/dashboard')
|
||||
} else if (e.key === 'Q' || e.key === 'q') {
|
||||
e.preventDefault()
|
||||
if (confirm('Reboot the server?')) {
|
||||
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await store.logout()
|
||||
} catch {
|
||||
/* proceed to login regardless */
|
||||
}
|
||||
router.push('/login').catch(() => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Styles extracted to dashboard/dashboard-styles.css -->
|
||||
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<div class="discover-container">
|
||||
<!-- Navigation Bar (always at top) -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div ref="discoverHeaderRef" class="app-header-desktop mb-6 items-center gap-4 relative">
|
||||
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="discover-category-select">App Store category</label>
|
||||
<select
|
||||
id="discover-category-select"
|
||||
class="segmented-select-control"
|
||||
value="discover"
|
||||
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: categories + search -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
data-controller-no-submit
|
||||
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero + Featured + Banner (only when no search) -->
|
||||
<template v-if="!searchQuery">
|
||||
<DiscoverHero
|
||||
:total-apps="allApps.length"
|
||||
:installed-count="installedCount"
|
||||
/>
|
||||
|
||||
<FeaturedApps
|
||||
:featured-apps="featuredApps"
|
||||
:show-stagger="showStagger"
|
||||
:containers-scanned="containersScanned"
|
||||
:installing-apps="installingApps"
|
||||
:is-installed="isInstalled"
|
||||
:is-starting-up="isStartingUp"
|
||||
:get-app-tier="getAppTier"
|
||||
@view-details="viewAppDetails"
|
||||
@launch="launchInstalledApp"
|
||||
@install="handleInstall"
|
||||
/>
|
||||
|
||||
<!-- Featured App Banner (from catalog or hardcoded) -->
|
||||
<div
|
||||
v-if="featuredBanner"
|
||||
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
|
||||
>
|
||||
<img
|
||||
:src="featuredBanner.banner"
|
||||
:alt="featuredBanner.headline"
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="featured-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
|
||||
@click.stop="launchInstalledApp(featuredBannerApp)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Launch</button>
|
||||
<button
|
||||
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
|
||||
@click.stop="handleInstall(featuredBannerApp)"
|
||||
:disabled="installingApps.has(featuredBannerApp.id)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile companion app banner — opens the download/pairing modal -->
|
||||
<CompanionBanner />
|
||||
|
||||
<!-- Category Section Divider -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">all</span>
|
||||
<h2 class="text-xl font-bold text-white">Available to Install</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search results header -->
|
||||
<div v-else class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">search</span>
|
||||
<h2 class="text-xl font-bold text-white">Search Results</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
|
||||
<!-- Community Load Error -->
|
||||
<div v-if="communityError" class="alert-error mb-4">
|
||||
{{ communityError }}
|
||||
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
||||
</div>
|
||||
|
||||
<AppGrid
|
||||
:filtered-apps="filteredApps"
|
||||
:show-stagger="showStagger"
|
||||
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
|
||||
:containers-scanned="containersScanned"
|
||||
:installing-apps="installingApps"
|
||||
:is-installed="isInstalled"
|
||||
:is-starting-up="isStartingUp"
|
||||
:get-installed-state="getInstalledState"
|
||||
:get-app-tier="getAppTier"
|
||||
:is-loading="loadingCommunity || nostrLoading"
|
||||
:loading-message="nostrLoading ? 'Querying Nostr relays...' : 'Loading...'"
|
||||
:nostr-error="nostrError"
|
||||
:is-nostr-category="selectedCategory === 'nostr'"
|
||||
:search-query="searchQuery"
|
||||
@view-details="viewAppDetails"
|
||||
@launch="launchInstalledApp"
|
||||
@install="handleInstall"
|
||||
@retry-nostr="retryNostr"
|
||||
/>
|
||||
|
||||
<!-- Manifesto Footer (only when no search) -->
|
||||
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag text-orange-400/80">manifesto</span>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
|
||||
"Privacy is not about having something to hide. Privacy is about having the right to choose
|
||||
what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance.
|
||||
Every service you run on your own hardware is a vote for a future where individuals — not
|
||||
corporations — control their digital lives."
|
||||
</blockquote>
|
||||
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
</div>
|
||||
|
||||
<!-- First-install version chooser (Bitcoin Knots / Core) -->
|
||||
<InstallVersionModal
|
||||
:show="showInstallModal"
|
||||
:app-id="installModalApp?.id || ''"
|
||||
:app="installModalApp"
|
||||
@close="showInstallModal = false; installModalApp = null"
|
||||
@confirm="onInstallModalConfirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let discoverAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import CompanionBanner from './discover/CompanionBanner.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
|
||||
const showStagger = !discoverAnimationDone
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
const discoverHeaderRef = ref<HTMLElement | null>(null)
|
||||
const discoverPrimaryRef = ref<HTMLElement | null>(null)
|
||||
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
discoverHeaderRef,
|
||||
discoverPrimaryRef,
|
||||
discoverCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation.
|
||||
// The store's watcher (stores/server.ts) handles install-progress updates
|
||||
// globally, so this view doesn't need its own watcher. Previously had a
|
||||
// local watcher that duplicated logic using byte counters only — it has
|
||||
// been removed in favour of the store's phase-aware mapping.
|
||||
const installingApps = serverStore.installingApps
|
||||
|
||||
// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core)
|
||||
const showInstallModal = ref(false)
|
||||
const installModalApp = ref<MarketplaceApp | null>(null)
|
||||
|
||||
function navigateToMarketplace(categoryId: string) {
|
||||
router.push({ name: 'marketplace', query: { category: categoryId } })
|
||||
}
|
||||
|
||||
function selectDiscoverCategory(categoryId: string) {
|
||||
if (categoryId === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
navigateToMarketplace(categoryId)
|
||||
}
|
||||
|
||||
// Community & Nostr marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
const nostrLoading = ref(false)
|
||||
const nostrError = ref('')
|
||||
|
||||
async function loadNostrMarketplace() {
|
||||
if (nostrApps.value.length > 0 || nostrLoading.value) return
|
||||
nostrLoading.value = true
|
||||
nostrError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.marketplaceDiscover()
|
||||
nostrApps.value = res.apps.map(app => ({
|
||||
id: app.manifest.app_id,
|
||||
title: app.manifest.name,
|
||||
version: app.manifest.version,
|
||||
description: typeof app.manifest.description === 'string'
|
||||
? app.manifest.description
|
||||
: app.manifest.description,
|
||||
icon: app.manifest.icon_url || '',
|
||||
author: app.manifest.author.name,
|
||||
dockerImage: app.manifest.container.image,
|
||||
repoUrl: app.manifest.repo_url,
|
||||
category: app.manifest.category,
|
||||
source: 'nostr',
|
||||
trustScore: app.trust_score,
|
||||
trustTier: app.trust_tier,
|
||||
relayCount: app.relay_count,
|
||||
}))
|
||||
} catch (e) {
|
||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
if (import.meta.env.DEV) console.warn('Nostr marketplace discovery failed:', e)
|
||||
} finally {
|
||||
nostrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retryNostr() {
|
||||
nostrApps.value = []
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
|
||||
const installedPackages = computed(() => store.data?.['package-data'] || {})
|
||||
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||
// timeout, treat the scan as done so cards render their normal install state.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
|
||||
|
||||
const allApps = computed(() => {
|
||||
const local: (MarketplaceApp & { category: string; source: string })[] = []
|
||||
const community = communityApps.value.map(app => ({
|
||||
...app,
|
||||
category: categorizeCommunityApp(app),
|
||||
source: 'community'
|
||||
}))
|
||||
const base = [...local, ...community]
|
||||
|
||||
if (nostrApps.value.length > 0) {
|
||||
const existingIds = new Set(base.map(a => a.id))
|
||||
const nostrMerged = nostrApps.value
|
||||
.filter(app => !existingIds.has(app.id))
|
||||
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
|
||||
return [...base, ...nostrMerged]
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
app.title?.toLowerCase().includes(query) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(query)) ||
|
||||
(typeof app.description === 'object' && app.description?.short?.toLowerCase().includes(query)) ||
|
||||
app.id?.toLowerCase().includes(query) ||
|
||||
app.author?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
// Hide installed apps and web-only links (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && app.dockerImage)
|
||||
return apps
|
||||
})
|
||||
|
||||
const installedCount = computed(() => {
|
||||
return allApps.value.filter(app => isInstalled(app.id)).length
|
||||
})
|
||||
|
||||
// Featured banner — from catalog.json or first FEATURED_DEFINITIONS entry with banner
|
||||
const featuredBanner = computed(() => {
|
||||
if (catalogFeatured.value) return catalogFeatured.value
|
||||
const first = FEATURED_DEFINITIONS.find(f => f.banner)
|
||||
if (!first) return null
|
||||
const app = allApps.value.find(a => a.id === first.id)
|
||||
if (!app) return null
|
||||
return { id: first.id, banner: first.banner!, headline: app.title ?? first.id, description: first.desc, tag: first.tag }
|
||||
})
|
||||
|
||||
const featuredBannerApp = computed(() => {
|
||||
if (!featuredBanner.value) return null
|
||||
return allApps.value.find(a => a.id === featuredBanner.value!.id) ?? null
|
||||
})
|
||||
|
||||
const featuredApps = computed<FeaturedApp[]>(() => {
|
||||
return FEATURED_DEFINITIONS
|
||||
.map(f => {
|
||||
const app = allApps.value.find(a => a.id === f.id)
|
||||
if (!app) return null
|
||||
return { ...app, featuredDescription: f.desc, privacyTag: f.tag } as FeaturedApp
|
||||
})
|
||||
.filter((a): a is FeaturedApp => a !== null)
|
||||
})
|
||||
|
||||
|
||||
function isInstalled(appId: string): boolean {
|
||||
if (appId in installedPackages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
||||
}
|
||||
|
||||
function getInstalledState(appId: string): string | null {
|
||||
const pkg = installedPackages.value[appId]
|
||||
if (pkg) return pkg.state
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const a of aliases) {
|
||||
const aliasPkg = installedPackages.value[a]
|
||||
if (aliasPkg) return aliasPkg.state
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isStartingUp(appId: string): boolean {
|
||||
const state = getInstalledState(appId)
|
||||
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
|
||||
}
|
||||
|
||||
function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
||||
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
async function handleInstall(app: MarketplaceApp) {
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(app.id)
|
||||
if (info.supportsVersions && info.versions.length > 1) {
|
||||
installModalApp.value = app
|
||||
showInstallModal.value = true
|
||||
return
|
||||
}
|
||||
} catch { /* no catalog versions — fall through to direct install */ }
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (app.source === 'local') {
|
||||
installApp(app, versionOverride)
|
||||
} else {
|
||||
installCommunityApp(app, versionOverride)
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version)
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
try {
|
||||
if (isInstalled(app.id)) {
|
||||
router.push({ name: 'app-details', params: { id: app.id }, query: { from: 'discover' } })
|
||||
} else {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'discover' } })
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[Discover] Navigation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Timer management
|
||||
const activeTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function trackTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
const idx = activeTimers.indexOf(id)
|
||||
if (idx !== -1) activeTimers.splice(idx, 1)
|
||||
fn()
|
||||
}, ms)
|
||||
activeTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of activeTimers) clearTimeout(t)
|
||||
activeTimers.length = 0
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Queued…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message,
|
||||
attempt: 0,
|
||||
})
|
||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||
}
|
||||
await rpcClient.call({ method: 'package.install', params: installParams, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
discoverAnimationDone = true
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
// Try dynamic catalog first, fall back to hardcoded
|
||||
const catalog = await fetchAppCatalog()
|
||||
if (catalog) {
|
||||
communityApps.value = catalog.apps
|
||||
catalogFeatured.value = catalog.featured
|
||||
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
|
||||
} else {
|
||||
communityApps.value = getCuratedAppList()
|
||||
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
|
||||
}
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,708 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<FederationHeader
|
||||
:self-did="selfDid"
|
||||
:server-name="appStore.serverName"
|
||||
@rotate="showRotateModal = true"
|
||||
/>
|
||||
|
||||
<RotateDidModal
|
||||
:visible="showRotateModal"
|
||||
:rotating="rotatingDid"
|
||||
:error="rotateError"
|
||||
:success="rotateSuccess"
|
||||
@close="showRotateModal = false; rotateError = ''; rotateSuccess = ''"
|
||||
@rotate="rotateDid"
|
||||
/>
|
||||
|
||||
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
|
||||
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
|
||||
<button
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.id"
|
||||
class="mode-switcher-btn"
|
||||
role="tab"
|
||||
:aria-selected="activeView === tab.id"
|
||||
:class="{ 'mode-switcher-btn-active': activeView === tab.id }"
|
||||
@click="setView(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Network Map View -->
|
||||
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
|
||||
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
|
||||
</div>
|
||||
|
||||
<template v-if="activeView === 'list'">
|
||||
<QuickActions
|
||||
:generating-invite="generatingInvite"
|
||||
:invite-type="inviteType"
|
||||
:invite-code="inviteCode"
|
||||
:syncing="syncing"
|
||||
@generate-invite="handleGenerateInvite"
|
||||
@show-join="showJoinModal = true"
|
||||
@sync="syncAll"
|
||||
@clear-invite="inviteCode = ''"
|
||||
/>
|
||||
|
||||
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
|
||||
Renders inline so the Federation page is the single place a user
|
||||
manages everything related to peering. The toggle directly mutates
|
||||
the `nostr_discovery_enabled` config flag — backend defaults to OFF
|
||||
and nothing is published until the user explicitly turns it on. -->
|
||||
<div class="glass-card p-4 mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">Nostr discoverability</span>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
|
||||
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mt-1">
|
||||
When on, this node publishes a presence event (DID + npub only — never an onion)
|
||||
so other nodes can find you and request to peer. Inbound requests land in the
|
||||
panel below for your approval. Off by default.
|
||||
</p>
|
||||
<button
|
||||
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
|
||||
@click="showSigningInfo = !showSigningInfo"
|
||||
>
|
||||
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discoveryToggling"
|
||||
@click="toggleDiscovery"
|
||||
>
|
||||
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="!discoveryEnabled"
|
||||
@click="showDiscoverModal = true"
|
||||
>
|
||||
Discover Nodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signing details: shows WHO signs the presence event (always the
|
||||
node's own discovery key — deliberately not pickable, so a user's
|
||||
personal Web5/Nostr identities can never be burned for node
|
||||
discovery) and a human-readable view of WHAT gets signed. -->
|
||||
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
|
||||
<div>
|
||||
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
|
||||
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
|
||||
node discovery always signs with the node's own key, never a
|
||||
personal identity, so there is nothing to pick. -->
|
||||
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
|
||||
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Presence events are always signed with this node's dedicated discovery key.
|
||||
Your personal identities are never used for node discovery, so unlike the app
|
||||
sign-in overlay there is nothing to choose here.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed & published</label>
|
||||
<dl class="text-xs space-y-1.5">
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
|
||||
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
|
||||
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
|
||||
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2">
|
||||
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
|
||||
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-white/40 mt-2">
|
||||
Every presence event carries a Schnorr signature (NIP-01) made with the key
|
||||
above — relays reject unsigned events, so anything you see via discovery was
|
||||
cryptographically signed by its node. Your onion address is never part of this
|
||||
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
|
||||
|
||||
<PendingRequestsPanel
|
||||
:requests="pendingRequests"
|
||||
:polling="pollingHandshake"
|
||||
:busy-id="pendingBusyId"
|
||||
@poll="pollHandshake"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
@cancel="cancelPending"
|
||||
/>
|
||||
|
||||
<NodeList
|
||||
:nodes="nodes"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:sync-results="syncResults"
|
||||
:dwn-sync-dot-class="dwnSyncDotClass"
|
||||
:cleaning-nodes="cleaningNodes"
|
||||
@select-node="selectedNode = $event"
|
||||
@clear-sync-results="syncResults = []"
|
||||
@cleanup-dead="cleanupDeadNodes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<NodeDetailModal
|
||||
:node="selectedNode"
|
||||
:dwn-sync-dot-class="dwnSyncDotClass"
|
||||
:dwn-sync-label="dwnSyncLabel"
|
||||
:dwn-message-count="String(dwnStatus?.message_count ?? '--')"
|
||||
:dwn-last-sync="dwnStatus?.last_sync ? timeAgo(dwnStatus.last_sync) : 'never'"
|
||||
:dwn-syncing="dwnSyncing"
|
||||
:deploying="deploying"
|
||||
:deploy-result="deployResult"
|
||||
@close="selectedNode = null"
|
||||
@change-trust="changeTrust"
|
||||
@remove-node="removeNode"
|
||||
@deploy-app="deployApp"
|
||||
@dwn-sync="triggerDwnSync"
|
||||
/>
|
||||
|
||||
<JoinModal
|
||||
:visible="showJoinModal"
|
||||
:joining="joining"
|
||||
:error="joinError"
|
||||
:success="joinSuccess"
|
||||
@close="showJoinModal = false"
|
||||
@join="joinFederation"
|
||||
/>
|
||||
|
||||
<DiscoverModal
|
||||
:visible="showDiscoverModal"
|
||||
:outbound-sent="pendingRequests"
|
||||
@close="showDiscoverModal = false"
|
||||
@sent="loadPendingRequests"
|
||||
/>
|
||||
|
||||
<PresenceSignModal
|
||||
:show="showPresenceSignModal"
|
||||
:server-name="appStore.serverName"
|
||||
:npub="nodeNpub"
|
||||
:did="selfDid"
|
||||
:version="appVersion"
|
||||
:busy="discoveryToggling"
|
||||
@confirm="confirmPresenceSign"
|
||||
@cancel="showPresenceSignModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
||||
import FederationHeader from './federation/FederationHeader.vue'
|
||||
import RotateDidModal from './federation/RotateDidModal.vue'
|
||||
import QuickActions from './federation/QuickActions.vue'
|
||||
import NodeList from './federation/NodeList.vue'
|
||||
import NodeDetailModal from './federation/NodeDetailModal.vue'
|
||||
import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import PresenceSignModal from './federation/PresenceSignModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
const syncStore = useSyncStore()
|
||||
|
||||
const nodes = ref<FederatedNode[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const selectedNode = ref<FederatedNode | null>(null)
|
||||
const inviteType = ref<'trusted' | 'observer'>('trusted')
|
||||
|
||||
const inviteCode = ref('')
|
||||
const generatingInvite = ref(false)
|
||||
|
||||
const showJoinModal = ref(false)
|
||||
const joining = ref(false)
|
||||
const joinError = ref('')
|
||||
const joinSuccess = ref(false)
|
||||
|
||||
const syncing = ref(false)
|
||||
const syncResults = ref<SyncResult[]>([])
|
||||
|
||||
const deploying = ref(false)
|
||||
const deployResult = ref('')
|
||||
|
||||
const viewTabs = [
|
||||
{ id: 'list', label: 'List View' },
|
||||
{ id: 'map', label: 'Network Map' },
|
||||
] as const
|
||||
|
||||
type ViewId = typeof viewTabs[number]['id']
|
||||
const activeView = ref<ViewId>(
|
||||
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
|
||||
)
|
||||
|
||||
function setView(id: ViewId) {
|
||||
activeView.value = id
|
||||
localStorage.setItem('federation-view', id)
|
||||
}
|
||||
|
||||
const selfDid = ref('')
|
||||
|
||||
const mapNodes = computed(() => {
|
||||
const result = []
|
||||
if (selfDid.value) {
|
||||
result.push({
|
||||
did: selfDid.value,
|
||||
label: appStore.serverName,
|
||||
trust_level: 'trusted' as const,
|
||||
online: true,
|
||||
app_count: 0,
|
||||
is_self: true,
|
||||
})
|
||||
}
|
||||
for (const node of nodes.value) {
|
||||
result.push({
|
||||
did: node.did,
|
||||
label: nodeName(node),
|
||||
trust_level: node.trust_level as 'trusted' | 'observer' | 'untrusted',
|
||||
online: isOnlineCheck(node),
|
||||
app_count: node.last_state?.apps?.length ?? 0,
|
||||
is_self: false,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const mapLinks = computed(() => {
|
||||
if (!selfDid.value) return []
|
||||
return nodes.value.map(n => ({
|
||||
source: selfDid.value,
|
||||
target: n.did,
|
||||
}))
|
||||
})
|
||||
|
||||
const dwnStatus = ref<DwnStatus | null>(null)
|
||||
const dwnSyncing = ref(false)
|
||||
|
||||
const dwnSyncDotClass = computed(() => {
|
||||
if (!dwnStatus.value) return 'bg-white/30'
|
||||
switch (dwnStatus.value.sync_status) {
|
||||
case 'synced': return 'bg-green-400'
|
||||
case 'syncing': return 'bg-yellow-400 animate-pulse'
|
||||
case 'error': return 'bg-red-400'
|
||||
default: return 'bg-white/30'
|
||||
}
|
||||
})
|
||||
|
||||
const dwnSyncLabel = computed(() => {
|
||||
if (!dwnStatus.value) return 'Unknown'
|
||||
switch (dwnStatus.value.sync_status) {
|
||||
case 'synced': return 'Synced'
|
||||
case 'syncing': return 'Syncing...'
|
||||
case 'error': return 'Error'
|
||||
default: return dwnStatus.value.sync_status || 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
// DID rotation
|
||||
const showRotateModal = ref(false)
|
||||
const rotatingDid = ref(false)
|
||||
const rotateError = ref('')
|
||||
const rotateSuccess = ref('')
|
||||
|
||||
// Dead node cleanup
|
||||
const cleaningNodes = ref(false)
|
||||
|
||||
// Nostr discoverability + pending peer requests
|
||||
const discoveryEnabled = ref(false)
|
||||
const discoveryToggling = ref(false)
|
||||
const discoveryError = ref('')
|
||||
const showDiscoverModal = ref(false)
|
||||
// Signing-details disclosure: who signs the presence event (always the node's
|
||||
// own discovery key) and a human-readable view of the signed content.
|
||||
const showSigningInfo = ref(false)
|
||||
const showPresenceSignModal = ref(false)
|
||||
const nodeNpub = ref('')
|
||||
const appVersion = computed(() => syncStore.serverInfo?.version || '')
|
||||
|
||||
function truncateNpub(npub: string): string {
|
||||
if (npub.length <= 20) return npub
|
||||
return npub.slice(0, 12) + '...' + npub.slice(-6)
|
||||
}
|
||||
|
||||
async function loadNodeNpub() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
|
||||
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
|
||||
} catch {
|
||||
// Nostr key not provisioned yet — panel shows a placeholder
|
||||
}
|
||||
}
|
||||
const pendingRequests = ref<PendingPeerRequest[]>([])
|
||||
const pollingHandshake = ref(false)
|
||||
const pendingBusyId = ref<string | null>(null)
|
||||
|
||||
async function loadDiscoveryState() {
|
||||
try {
|
||||
const result = await rpcClient.nostrDiscoveryStatus()
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch {
|
||||
discoveryEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiscovery() {
|
||||
if (!discoveryEnabled.value) {
|
||||
// Enabling publishes a signed presence event — surface the signer overlay
|
||||
// first so the user sees exactly what gets signed and with which key.
|
||||
// Nothing is published until they confirm.
|
||||
if (!nodeNpub.value) loadNodeNpub()
|
||||
showPresenceSignModal.value = true
|
||||
return
|
||||
}
|
||||
// Disabling publishes nothing new — apply immediately.
|
||||
discoveryToggling.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.nostrSetDiscovery(false)
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
|
||||
} finally {
|
||||
discoveryToggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPresenceSign() {
|
||||
discoveryToggling.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.nostrSetDiscovery(true)
|
||||
discoveryEnabled.value = !!result.enabled
|
||||
showPresenceSignModal.value = false
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to enable discoverability'
|
||||
} finally {
|
||||
discoveryToggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const result = await rpcClient.federationListPendingRequests()
|
||||
pendingRequests.value = result.requests
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests'
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHandshake() {
|
||||
pollingHandshake.value = true
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.handshakePoll()
|
||||
await loadPendingRequests()
|
||||
// If a poll applied a PeerInvite, the federation node list also changed.
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Poll failed'
|
||||
} finally {
|
||||
pollingHandshake.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approvePending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationApproveRequest(id)
|
||||
await loadPendingRequests()
|
||||
await loadNodes()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Approve failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
await rpcClient.federationRejectRequest(id)
|
||||
await loadPendingRequests()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Reject failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPending(id: string) {
|
||||
pendingBusyId.value = id
|
||||
discoveryError.value = ''
|
||||
try {
|
||||
// Default notify=true from the rpc-client — the peer's inbound row
|
||||
// disappears from their UI so they don't have to wonder about a
|
||||
// stale handshake.
|
||||
await rpcClient.federationCancelRequest(id)
|
||||
await loadPendingRequests()
|
||||
} catch (e: unknown) {
|
||||
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
|
||||
} finally {
|
||||
pendingBusyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isOnlineCheck(node: FederatedNode): boolean {
|
||||
if (!node.last_seen) return false
|
||||
const lastSeen = new Date(node.last_seen).getTime()
|
||||
const tenMinutesAgo = Date.now() - 10 * 60 * 1000
|
||||
return lastSeen > tenMinutesAgo
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
return loadNodesWithOptions()
|
||||
}
|
||||
|
||||
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
||||
const showLoader = options.showLoader ?? nodes.value.length === 0
|
||||
const surfaceErrors = options.surfaceErrors ?? true
|
||||
try {
|
||||
if (showLoader) loading.value = true
|
||||
const result = await rpcClient.federationListNodes()
|
||||
nodes.value = result.nodes
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
if (surfaceErrors) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||
}
|
||||
} finally {
|
||||
if (showLoader) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
inviteType.value = type
|
||||
generateInvite()
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
generatingInvite.value = true
|
||||
error.value = ''
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value)
|
||||
inviteCode.value = result.code
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinFederation(code: string) {
|
||||
try {
|
||||
joining.value = true
|
||||
joinError.value = ''
|
||||
joinSuccess.value = false
|
||||
await rpcClient.federationJoin(code)
|
||||
joinSuccess.value = true
|
||||
await loadNodes()
|
||||
setTimeout(() => { showJoinModal.value = false; joinSuccess.value = false }, 1500)
|
||||
} catch (e) {
|
||||
joinError.value = e instanceof Error ? e.message : 'Failed to join'
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
try {
|
||||
syncing.value = true
|
||||
error.value = ''
|
||||
syncResults.value = []
|
||||
const result = await rpcClient.call<{
|
||||
synced: number; failed: number;
|
||||
results: SyncResult[]
|
||||
}>({ method: 'federation.sync-state', timeout: 180000 })
|
||||
syncResults.value = result.results
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Sync failed — some peers may be unreachable over Tor'
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrust(did: string, level: string) {
|
||||
try {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(did: string) {
|
||||
try {
|
||||
await rpcClient.federationRemoveNode(did)
|
||||
selectedNode.value = null
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove node'
|
||||
}
|
||||
}
|
||||
|
||||
async function deployApp(did: string, appId: string) {
|
||||
try {
|
||||
deploying.value = true
|
||||
deployResult.value = ''
|
||||
await rpcClient.federationDeployApp({ did, appId })
|
||||
deployResult.value = `Successfully deployed ${appId} to remote node`
|
||||
} catch (e) {
|
||||
deployResult.value = `Error: ${e instanceof Error ? e.message : 'Deploy failed'}`
|
||||
} finally {
|
||||
deploying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDwnStatus() {
|
||||
try {
|
||||
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
|
||||
dwnStatus.value = result
|
||||
} catch {
|
||||
dwnStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDwnSync() {
|
||||
try {
|
||||
dwnSyncing.value = true
|
||||
await rpcClient.call({ method: 'dwn.sync', timeout: 120000 })
|
||||
await loadDwnStatus()
|
||||
} catch {
|
||||
// Silently handle sync errors
|
||||
} finally {
|
||||
dwnSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDeadNodes() {
|
||||
cleaningNodes.value = true
|
||||
try {
|
||||
const deadNodes = nodes.value.filter(n => !isOnlineCheck(n) && (!n.last_seen || n.last_seen === 'never'))
|
||||
for (const node of deadNodes) {
|
||||
await rpcClient.federationRemoveNode(node.did)
|
||||
}
|
||||
await loadNodes()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Cleanup failed'
|
||||
} finally {
|
||||
cleaningNodes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateDid(password: string) {
|
||||
if (!password) return
|
||||
rotatingDid.value = true
|
||||
rotateError.value = ''
|
||||
rotateSuccess.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{
|
||||
old_did: string; new_did: string; proof_signature: string; proof_message: string
|
||||
}>({ method: 'node.rotate-did', params: { password } })
|
||||
|
||||
selfDid.value = result.new_did
|
||||
try { localStorage.setItem('neode_did', result.new_did) } catch { /* noop */ }
|
||||
rotateSuccess.value = `DID rotated. Notifying peers...`
|
||||
|
||||
const notify = await rpcClient.call<{ notified: number; failed: number }>({
|
||||
method: 'federation.notify-did-change',
|
||||
params: {
|
||||
old_did: result.old_did,
|
||||
new_did: result.new_did,
|
||||
proof_signature: result.proof_signature,
|
||||
proof_message: result.proof_message,
|
||||
},
|
||||
timeout: 120000,
|
||||
})
|
||||
rotateSuccess.value = `DID rotated successfully. ${notify.notified} peers notified${notify.failed > 0 ? `, ${notify.failed} failed` : ''}.`
|
||||
} catch (err: unknown) {
|
||||
rotateError.value = err instanceof Error ? err.message : 'Rotation failed'
|
||||
} finally {
|
||||
rotatingDid.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodesWithOptions({ showLoader: true })
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
loadNodeNpub()
|
||||
transportStore.fetchPeers()
|
||||
try {
|
||||
const result = await rpcClient.getNodeDid()
|
||||
selfDid.value = result.did
|
||||
} catch {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6 mobile-scroll-pad">
|
||||
<BackButton label="Web5" desktop-margin="mb-6" @click="router.push('/dashboard/web5')" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-center min-[1440px]:justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
|
||||
<p class="text-white/70">Beta Telemetry — monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact col-span-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap min-[1440px]:col-span-1 min-[1440px]:min-w-[220px]">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<div class="md:hidden mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
|
||||
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact mb-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
|
||||
<div class="glass-card p-8 max-w-md text-center">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Loading fleet data</h3>
|
||||
<p class="text-white/60 text-sm">Checking beta telemetry reports from connected nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="fleet.errorMessage.value" class="glass-card p-6 mb-6">
|
||||
<div class="alert-error rounded-lg mb-4">{{ fleet.errorMessage.value }}</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<FleetOverviewCards
|
||||
:node-count="fleet.nodes.value.length"
|
||||
:online-count="fleet.onlineCount.value"
|
||||
:offline-count="fleet.offlineCount.value"
|
||||
:fleet-health-pct="fleet.fleetHealthPct.value"
|
||||
:healthy-count="fleet.healthyCount.value"
|
||||
:avg-cpu="fleet.avgCpu.value"
|
||||
:avg-mem="fleet.avgMem.value"
|
||||
:avg-disk="fleet.avgDisk.value"
|
||||
/>
|
||||
|
||||
<div v-if="fleet.refreshing.value && fleet.nodes.value.length > 0" class="glass-card p-3 mb-4 text-sm text-white/60 flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 text-white/50" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing fleet telemetry...
|
||||
</div>
|
||||
|
||||
<FleetNodeGrid
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
:sort-by="fleet.sortBy.value"
|
||||
:selected-node-id="fleet.selectedNodeId.value"
|
||||
@update:sort-by="fleet.sortBy.value = $event"
|
||||
@select-node="fleet.selectNode"
|
||||
/>
|
||||
|
||||
<FleetAlerts
|
||||
:alerts="fleet.fleetAlerts.value"
|
||||
:alerts-loading="fleet.alertsLoading.value"
|
||||
/>
|
||||
|
||||
<FleetNodeDetail
|
||||
v-if="fleet.selectedNodeId.value && fleet.selectedNode.value"
|
||||
:node="fleet.selectedNode.value"
|
||||
:node-id="fleet.selectedNodeId.value"
|
||||
:history-loading="fleet.nodeHistoryLoading.value"
|
||||
:history-labels="fleet.nodeHistoryLabels.value"
|
||||
:cpu-datasets="fleet.nodeHistoryCpuDatasets.value"
|
||||
:mem-datasets="fleet.nodeHistoryMemDatasets.value"
|
||||
:disk-datasets="fleet.nodeHistoryDiskDatasets.value"
|
||||
:chart-width="fleet.chartWidth.value"
|
||||
@close="fleet.selectedNodeId.value = null"
|
||||
/>
|
||||
|
||||
<FleetContainerMatrix
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
:all-app-ids="fleet.allAppIds.value"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
{{ fleet.autoRefresh.value ? 'Auto-refreshing every 60s' : 'Auto-refresh paused' }}
|
||||
· Last updated {{ fleet.lastRefreshed.value ? timeAgo(fleet.lastRefreshed.value) : 'never' }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
|
||||
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
|
||||
import FleetAlerts from './fleet/FleetAlerts.vue'
|
||||
import FleetNodeDetail from './fleet/FleetNodeDetail.vue'
|
||||
import FleetContainerMatrix from './fleet/FleetContainerMatrix.vue'
|
||||
import { useFleetData, timeAgo } from './fleet/useFleetData'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const fleet = useFleetData()
|
||||
</script>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Goal wizard -->
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ goal.title }}</h1>
|
||||
<p class="text-white/70">{{ goal.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="overallStatus === 'completed' ? 'bg-green-400' : 'bg-orange-400'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sovereignty patience message for bitcoin-dependent goals -->
|
||||
<div
|
||||
v-if="showSyncMessage"
|
||||
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm leading-relaxed">
|
||||
{{ t('goalDetail.syncMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(step, idx) in goal.steps"
|
||||
:key="step.id"
|
||||
class="glass-card p-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="goal-step"
|
||||
:class="{
|
||||
'goal-step-completed': isStepCompleted(step),
|
||||
'goal-step-active': idx === activeStepIndex && overallStatus !== 'completed',
|
||||
'goal-step-pending': idx > activeStepIndex && !isStepCompleted(step),
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Step indicator -->
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<div v-if="isStepCompleted(step)" class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="idx === activeStepIndex && isInstalling" class="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-orange-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span class="text-xs text-white/40 font-medium">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App icon -->
|
||||
<img
|
||||
v-if="stepIconUrl(step)"
|
||||
:src="stepIconUrl(step)"
|
||||
:alt="step.title"
|
||||
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
|
||||
<p class="text-sm text-white/55 leading-relaxed">{{ step.description }}</p>
|
||||
|
||||
<!-- Action button for active step -->
|
||||
<div v-if="idx === activeStepIndex && overallStatus !== 'completed'" class="mt-4">
|
||||
<button
|
||||
v-if="step.action === 'install' && step.appId && !isAppInstalled(step.appId)"
|
||||
@click="installApp(step)"
|
||||
:disabled="isInstalling"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
|
||||
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
|
||||
<div v-else-if="step.action === 'fund'" class="space-y-3">
|
||||
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<span class="text-xs text-white/75">Bitcoin is syncing — funding unlocks when it finishes</span>
|
||||
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
|
||||
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
|
||||
</div>
|
||||
<p class="text-xs text-white/50">
|
||||
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
|
||||
<span v-else>Estimating time remaining…</span>
|
||||
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-white/60">On-chain wallet balance</span>
|
||||
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
|
||||
{{ walletOnchainSats.toLocaleString() }} sats
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="showFundModal = true"
|
||||
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Fund Wallet
|
||||
</button>
|
||||
<button
|
||||
@click="completeFundStep(step)"
|
||||
:disabled="walletOnchainSats <= 0"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@click="completeVerifyStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.checkAndContinue') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.iveDoneThis') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
|
||||
>
|
||||
{{ t('goalDetail.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completion -->
|
||||
<div v-if="overallStatus === 'completed'" class="glass-card p-8 mt-6 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<button
|
||||
v-if="completionCta"
|
||||
@click="openCompletionTarget"
|
||||
class="glass-button rounded-lg px-6 py-3 font-medium"
|
||||
>
|
||||
{{ completionCta.label }}
|
||||
</button>
|
||||
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
|
||||
<ReceiveBitcoinModal
|
||||
:show="showFundModal"
|
||||
note="Fund your Lightning channel: minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||
auto-generate
|
||||
@close="showFundModal = false"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepRouteOverride } from './goals/goalStepActions'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
bitcoinSyncPercent,
|
||||
bitcoinBlockHeight,
|
||||
bitcoinSyncEtaText,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
lnd: '/assets/img/app-icons/lnd.png',
|
||||
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
|
||||
immich: '/assets/img/app-icons/immich.png',
|
||||
nextcloud: '/assets/img/app-icons/nextcloud.webp',
|
||||
fedimint: '/assets/img/app-icons/fedimint.png',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
electrs: '/assets/img/app-icons/electrumx.png',
|
||||
electrumx: '/assets/img/app-icons/electrumx.png',
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
if (step.icon) return step.icon
|
||||
if (!step.appId) return undefined
|
||||
return APP_ICON_MAP[step.appId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the completion card sends the user: the app they just set up, not the
|
||||
* generic services list. `launchAppId` opens via the app launcher (iframe apps
|
||||
* overlay on top of the current screen; X-Frame-Options apps open a tab).
|
||||
*/
|
||||
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
|
||||
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
|
||||
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
|
||||
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
|
||||
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
|
||||
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
|
||||
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
|
||||
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
|
||||
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const goalStore = useGoalStore()
|
||||
|
||||
const goalId = computed(() => route.params.goalId as string)
|
||||
const goal = computed(() => getGoalById(goalId.value))
|
||||
|
||||
const isInstalling = ref(false)
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
const overallStatus = computed(() => goalStore.getGoalStatus(goalId.value))
|
||||
|
||||
const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
// Only install steps auto-tick from package state — manual steps (fund the
|
||||
// wallet, open a channel, configure) must be walked through.
|
||||
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
}
|
||||
return completed
|
||||
})
|
||||
|
||||
const activeStepIndex = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
const steps = goal.value.steps
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
if (step && !completedSteps.value.has(step.id)) return i
|
||||
}
|
||||
return steps.length - 1
|
||||
})
|
||||
|
||||
const currentStepDisplay = computed(() => Math.min(activeStepIndex.value + 1, goal.value?.steps.length || 1))
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!goal.value) return 0
|
||||
return Math.round((completedSteps.value.size / goal.value.steps.length) * 100)
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (overallStatus.value === 'completed') return t('goalDetail.completed')
|
||||
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
|
||||
return t('goalDetail.notStarted')
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'goal-status-badge-completed'
|
||||
if (overallStatus.value === 'in-progress') return 'goal-status-badge-in-progress'
|
||||
return 'goal-status-badge-not-started'
|
||||
})
|
||||
|
||||
const showSyncMessage = computed(() => {
|
||||
if (!goal.value) return false
|
||||
const hasBitcoin = goal.value.requiredApps.includes('bitcoin-knots')
|
||||
const bitcoinNotRunning = !isAppRunning('bitcoin-knots')
|
||||
return hasBitcoin && bitcoinNotRunning && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
function isStepCompleted(step: GoalStep): boolean {
|
||||
return completedSteps.value.has(step.id)
|
||||
}
|
||||
|
||||
/** App ID aliases — backend may register under variant names */
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
|
||||
}
|
||||
|
||||
function matchesAppId(pkgId: string, appId: string): boolean {
|
||||
if (pkgId === appId) return true
|
||||
const aliases = APP_ALIASES[appId]
|
||||
return aliases ? aliases.includes(pkgId) : false
|
||||
}
|
||||
|
||||
function isAppInstalled(appId: string): boolean {
|
||||
return Object.keys(appStore.packages).some((pkgId) => matchesAppId(pkgId, appId))
|
||||
}
|
||||
|
||||
function isAppRunning(appId: string): boolean {
|
||||
return Object.entries(appStore.packages).some(
|
||||
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
|
||||
)
|
||||
}
|
||||
|
||||
async function installApp(step: GoalStep) {
|
||||
if (!step.appId) return
|
||||
isInstalling.value = true
|
||||
|
||||
ensureGoalStarted()
|
||||
|
||||
try {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
} catch (err) {
|
||||
showActionError(`Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
isInstalling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
const override = goalStepRouteOverride(step)
|
||||
if (override) {
|
||||
// Internal screens (channels, web5, settings) — tag where we came from so
|
||||
// their back button returns to this wizard.
|
||||
router.push({ path: override, query: { from: 'goal', goal: goalId.value } })
|
||||
} else if (step.appId) {
|
||||
// Launch the app itself: iframe apps overlay on top of the wizard,
|
||||
// tab-only apps open a tab (mobile: the in-app browser) — the app
|
||||
// launcher handles every case.
|
||||
useAppLauncherStore().openSession(step.appId)
|
||||
}
|
||||
}
|
||||
|
||||
function completeVerifyStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function completeInfoStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function ensureGoalStarted() {
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// The goal cards live on Home's Setup tab — return there, not the dashboard.
|
||||
router.push({ path: '/dashboard', query: { tab: 'setup' } })
|
||||
}
|
||||
|
||||
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
|
||||
|
||||
const showFundModal = ref(false)
|
||||
const walletOnchainSats = ref(0)
|
||||
|
||||
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
|
||||
const fundStepActive = computed(() => {
|
||||
if (!goal.value || !hasFundStep.value) return false
|
||||
const active = goal.value.steps[activeStepIndex.value]
|
||||
return active?.action === 'fund' && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
let releaseSync: (() => void) | null = null
|
||||
let balanceTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshWalletBalance() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
|
||||
walletOnchainSats.value = res.balance_sats || 0
|
||||
} catch { /* LND not up yet — balance stays at last known value */ }
|
||||
}
|
||||
|
||||
watch(fundStepActive, (active) => {
|
||||
if (active) {
|
||||
if (!releaseSync) releaseSync = acquireBitcoinSync()
|
||||
void refreshWalletBalance()
|
||||
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
|
||||
} else {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Refresh the balance right after the receive modal closes — the user may
|
||||
// have just sent funds.
|
||||
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
})
|
||||
|
||||
function completeFundStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
// ── Completion CTA: go to the app you just set up ────────────────────────────
|
||||
|
||||
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
|
||||
|
||||
function openCompletionTarget() {
|
||||
const cta = completionCta.value
|
||||
if (!cta) return
|
||||
if (cta.launchAppId) {
|
||||
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
|
||||
// (BTCPay, Nextcloud…) open in a new tab.
|
||||
useAppLauncherStore().openSession(cta.launchAppId)
|
||||
} else if (cta.route) {
|
||||
router.push(cta.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,649 @@
|
||||
<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 order-1 lg:order-none" :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 transition-all group-hover:-translate-y-1 group-hover:shadow-lg flex items-center justify-center">
|
||||
<img :src="app.icon" :alt="app.name" class="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
|
||||
</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 order-3 lg:order-none" :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>
|
||||
|
||||
<!-- Wallet Overview -->
|
||||
<HomeWalletCard
|
||||
class="order-2 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:wallet-connected="walletConnected"
|
||||
:wallet-onchain="walletOnchain"
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
:wallet-fedimint="walletFedimint"
|
||||
:wallet-ark="walletArk"
|
||||
:wallet-transactions="walletTransactions"
|
||||
:is-dev="isDev"
|
||||
@show-scan="showScanModal = true"
|
||||
@show-send="showSendModal = true"
|
||||
@show-receive="showReceiveModal = true"
|
||||
@show-transactions="showTransactionsModal = true"
|
||||
@show-wallet-settings="showWalletSettingsModal = true"
|
||||
@faucet="devFaucet"
|
||||
@open-in-mempool="openInMempool"
|
||||
/>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable order-4 lg:order-none" :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.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="vpnDotClass"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnTextClass">{{ vpnStatusLabel }}</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="bitcoinDotClass"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="bitcoinTextClass">{{ bitcoinSyncDisplay }}</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="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
|
||||
<span class="text-sm font-medium" :class="fipsTextClass">{{ fipsStatusLabel }}</span>
|
||||
</div>
|
||||
<div v-if="homeStatus.tollgateStatus" 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="tollgateDotClass"></div><span class="text-sm text-white/80">TollGate</span></div>
|
||||
<span class="text-sm font-medium" :class="tollgateTextClass">{{ tollgateStatusLabel }}</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>
|
||||
|
||||
<!-- App Store Recommendations -->
|
||||
<div
|
||||
v-if="homeRecommendedApps.length > 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable lg:col-span-2 order-5 lg:order-none"
|
||||
: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.recommendedApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" 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 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
|
||||
<button
|
||||
v-for="app in homeRecommendedApps"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
||||
@click="viewRecommendedApp(app)"
|
||||
>
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title || app.id"
|
||||
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
||||
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start Goals -->
|
||||
<div
|
||||
v-if="showQuickStart"
|
||||
class="home-card lg:col-span-2 transition-opacity duration-300 order-6 lg:order-none"
|
||||
: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 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-2 shrink-0">
|
||||
<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 gap-3 mt-auto">
|
||||
<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>
|
||||
|
||||
<!-- System Stats -->
|
||||
<HomeSystemCard
|
||||
class="order-7 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:loaded="systemStatsLoaded"
|
||||
:stats="systemStats"
|
||||
:uptime-display="systemUptimeDisplay"
|
||||
/>
|
||||
</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 -->
|
||||
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WalletScanModal from '@/components/WalletScanModal.vue'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||
import WalletSettingsModal from '@/components/WalletSettingsModal.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
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 { getAppUsage } from '@/utils/appUsage'
|
||||
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
||||
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 route = useRoute()
|
||||
const uiMode = useUIModeStore()
|
||||
const isDev = import.meta.env.DEV
|
||||
// ?tab=setup lands on the Setup tab (e.g. "Back to Goals" from a goal wizard)
|
||||
const homeTab = ref<'dashboard' | 'setup'>(route.query.tab === 'setup' ? 'setup' : 'dashboard')
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'setup') homeTab.value = 'setup'
|
||||
else if (tab === 'dashboard') homeTab.value = '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 homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
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)
|
||||
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
|
||||
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
|
||||
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
|
||||
})
|
||||
|
||||
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 = computed(() => {
|
||||
const usage = getAppUsage()
|
||||
return Object.entries(packages.value || {})
|
||||
.filter(([id, pkg]) => !isServicePackage(id, pkg) && !isWebsitePackage(id, pkg))
|
||||
.map(([id, pkg]) => ({
|
||||
id,
|
||||
name: pkg.manifest?.title || id,
|
||||
icon: resolveAppIcon(id, pkg),
|
||||
state: pkg.state,
|
||||
usage: usage[id]?.count || 0,
|
||||
lastLaunchedAt: usage[id]?.lastLaunchedAt || 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.usage !== a.usage) return b.usage - a.usage
|
||||
if (b.lastLaunchedAt !== a.lastLaunchedAt) return b.lastLaunchedAt - a.lastLaunchedAt
|
||||
if ((b.state === PackageState.Running ? 1 : 0) !== (a.state === PackageState.Running ? 1 : 0)) {
|
||||
return (b.state === PackageState.Running ? 1 : 0) - (a.state === PackageState.Running ? 1 : 0)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppList(), packages.value, 3))
|
||||
|
||||
function viewRecommendedApp(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
|
||||
}
|
||||
|
||||
function marketplaceDescription(app: MarketplaceApp) {
|
||||
if (typeof app.description === 'string') return app.description
|
||||
return app.description?.short || app.description?.long || ''
|
||||
}
|
||||
|
||||
// Network card data
|
||||
const torConnected = computed(() => {
|
||||
const torAddr = store.data?.['server-info']?.['tor-address']
|
||||
return !!torAddr && torAddr.length > 0
|
||||
})
|
||||
const vpnConnected = computed(() => homeStatus.vpnStatus.connected === true || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const vpnDotClass = computed(() => {
|
||||
if (vpnConnected.value) return 'bg-orange-400'
|
||||
return homeStatus.vpnKnown ? 'bg-white/40' : 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const vpnTextClass = computed(() => vpnConnected.value ? 'text-orange-400' : (homeStatus.vpnKnown ? 'text-white/40' : 'text-white/50'))
|
||||
const vpnStatusLabel = computed(() => {
|
||||
if (vpnConnected.value) return homeStatus.vpnStatus.provider || 'WireGuard'
|
||||
if (!homeStatus.vpnKnown) return 'Checking…'
|
||||
return 'Not configured'
|
||||
})
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
if (s.anchor_connected === false) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s) return homeStatus.fipsLoadState === 'loading' ? 'Checking…' : '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability in the label so the
|
||||
// Home and Server rows flip in sync with the FIPS card.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const tollgateDotClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
return s.enabled ? 'bg-green-400' : 'bg-yellow-400'
|
||||
})
|
||||
const tollgateTextClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
return s.enabled ? 'text-green-400' : 'text-yellow-400'
|
||||
})
|
||||
const tollgateStatusLabel = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s) return homeStatus.tollgateLoadState === 'loading' ? 'Checking…' : 'Not configured'
|
||||
if (!s.installed) return 'Not installed'
|
||||
return s.enabled ? 'Enabled' : 'Disabled'
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…'
|
||||
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
|
||||
if (homeStatus.stats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (homeStatus.stats.bitcoinSyncPercent < 0.01 && homeStatus.stats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${homeStatus.stats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
})
|
||||
const bitcoinDotClass = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === true) return 'bg-orange-400'
|
||||
if (homeStatus.stats.bitcoinAvailable === false) return 'bg-white/40'
|
||||
return 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const bitcoinTextClass = computed(() => homeStatus.stats.bitcoinAvailable ? 'text-orange-400' : (homeStatus.stats.bitcoinAvailable === null ? 'text-white/50' : 'text-white/40'))
|
||||
|
||||
// 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, 10000); checkUpdateStatus(); loadWeb5Status()
|
||||
// Poll wallet balances/transactions like Web5.vue does — without this a
|
||||
// pending on-chain receive (or a fresh instant payment) only shows up
|
||||
// after a manual wallet action or a remount.
|
||||
walletRefreshInterval = setInterval(loadWeb5Status, 30000)
|
||||
// Real-time wallet push (2026-07-22): the backend streams LND transaction
|
||||
// events and nudges /ws/db the moment a tx hits the mempool. Any push =
|
||||
// something changed → refetch the wallet (debounced; the RPC is cheap).
|
||||
// This is what makes an incoming 0-conf tx appear in seconds instead of
|
||||
// waiting out the 30s poll above.
|
||||
unsubscribeWs = wsClient.subscribe(() => {
|
||||
if (wsWalletDebounce) clearTimeout(wsWalletDebounce)
|
||||
wsWalletDebounce = setTimeout(() => { void loadWeb5Status() }, 800)
|
||||
})
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
const showScanModal = ref(false); const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = 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 walletFedimint = ref(0)
|
||||
const walletArk = ref(0)
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
// Overlay the local Mempool app when it's running; otherwise route through
|
||||
// the external-explorer consent flow (pruned nodes can't run Mempool).
|
||||
const txExplorer = useTxExplorer()
|
||||
function openInMempool(txHash: string) { txExplorer.openTx(txHash) }
|
||||
|
||||
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
|
||||
// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used
|
||||
// elsewhere in this file, so it's mapped into that shape below rather than
|
||||
// widening WalletTransaction itself with a pile of ecash-only fields.
|
||||
interface EcashTransaction {
|
||||
id: string
|
||||
tx_type: 'send' | 'receive'
|
||||
amount_sats: number
|
||||
timestamp: string
|
||||
description: string
|
||||
mint_url: string
|
||||
peer: string
|
||||
kind: 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
|
||||
return {
|
||||
tx_hash: tx.id,
|
||||
amount_sats: tx.amount_sats,
|
||||
direction: tx.tx_type === 'receive' ? 'incoming' : 'outgoing',
|
||||
num_confirmations: 1,
|
||||
time_stamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
|
||||
total_fees: 0,
|
||||
dest_addresses: [],
|
||||
label: tx.description,
|
||||
block_height: 0,
|
||||
kind: tx.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWeb5Status() {
|
||||
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||
// there is a balance"). On failure keep the last-known value — the refs start
|
||||
// at 0, so only the very first load before any success shows 0.
|
||||
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 }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
|
||||
// already unifies both) — previously only LND transactions were fetched
|
||||
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
|
||||
// appeared in the Transactions modal even though the balance included it.
|
||||
let lndTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ }
|
||||
let lightningTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ }
|
||||
let ecashTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ }
|
||||
walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp)
|
||||
}
|
||||
|
||||
// System stats
|
||||
const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
bitcoinStale: homeStatus.bitcoinStale,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
|
||||
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
let unsubscribeWs: (() => void) | null = null
|
||||
let wsWalletDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadSystemStats() {
|
||||
await homeStatus.refresh(packages.value)
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black flex items-center justify-center p-6">
|
||||
<div class="glass-card p-8 w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ t('kioskRecovery.title') }}</h1>
|
||||
<p class="text-sm text-white/50">{{ t('kioskRecovery.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Server IP -->
|
||||
<div class="bg-white/5 rounded-lg p-4 mb-4">
|
||||
<div class="text-xs text-white/50 mb-1">{{ t('kioskRecovery.serverAddress') }}</div>
|
||||
<div class="text-lg font-mono text-white font-medium">{{ serverIp || t('common.loading') }}</div>
|
||||
<div v-if="serverIp" class="text-xs text-white/40 mt-1">{{ t('kioskRecovery.webUi', { address: serverIp }) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="serverIp" class="bg-white/5 rounded-lg p-4 mb-4 flex flex-col items-center">
|
||||
<div class="text-xs text-white/50 mb-2">{{ t('kioskRecovery.scanForMobile') }}</div>
|
||||
<div class="bg-white p-3 rounded-lg inline-block">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="w-32 h-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.backend') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="backendHealthy ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="backendHealthy ? 'text-green-400' : 'text-red-400'">
|
||||
{{ backendHealthy ? t('common.healthy') : t('kioskRecovery.unreachable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.containers') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ containerCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('monitoring.diskUsage') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ diskUsage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button @click="goToLogin" class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('kioskRecovery.goToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-xs text-white/30">{{ t('kioskRecovery.lastChecked', { time: lastChecked }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const serverIp = ref('')
|
||||
const backendHealthy = ref(false)
|
||||
const containerCount = ref('—')
|
||||
const diskUsage = ref('—')
|
||||
const lastChecked = ref('—')
|
||||
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (!serverIp.value) return ''
|
||||
const url = `http://${serverIp.value}`
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=128x128&data=${encodeURIComponent(url)}`
|
||||
})
|
||||
|
||||
async function refreshDiagnostics() {
|
||||
lastChecked.value = new Date().toLocaleTimeString()
|
||||
|
||||
// Detect server IP from window location
|
||||
serverIp.value = window.location.hostname !== 'localhost'
|
||||
? window.location.hostname
|
||||
: '127.0.0.1'
|
||||
|
||||
// Check backend health
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(5000) })
|
||||
backendHealthy.value = res.ok
|
||||
} catch {
|
||||
backendHealthy.value = false
|
||||
}
|
||||
|
||||
// Get system stats (unauthenticated won't work for RPC, but try health)
|
||||
if (backendHealthy.value) {
|
||||
try {
|
||||
const statsRes = await fetch('/rpc/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'system.stats' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await statsRes.json()
|
||||
if (data.result) {
|
||||
const disk = data.result.disk
|
||||
if (disk) {
|
||||
const usedPct = ((disk.used / disk.total) * 100).toFixed(0)
|
||||
diskUsage.value = `${usedPct}% used`
|
||||
}
|
||||
containerCount.value = String(data.result.containers?.running ?? '—')
|
||||
}
|
||||
} catch {
|
||||
// Stats require auth — show defaults
|
||||
containerCount.value = '—'
|
||||
diskUsage.value = '—'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshDiagnostics()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative z-10 login-fly-perspective">
|
||||
<div class="w-full max-w-md relative z-20">
|
||||
<!-- Login Card - flies towards user on success -->
|
||||
<div
|
||||
class="glass-card p-8 pt-20 relative login-card overflow-visible"
|
||||
:class="{ 'login-fly-towards': whooshAway }"
|
||||
>
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||
<div class="logo-gradient-border w-20 h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
<span v-if="isCheckingSetup"> </span>
|
||||
<span v-else-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
|
||||
<span v-else>{{ t('login.title') }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Server Startup Progress -->
|
||||
<div v-if="!serverReady" class="mb-6" role="status" aria-live="polite">
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<svg class="animate-spin h-4 w-4 text-orange-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-white/60">{{ t('login.serverStarting') }}</span>
|
||||
</div>
|
||||
<div class="startup-progress-track">
|
||||
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Checking setup state -->
|
||||
<div v-if="isCheckingSetup" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Setup Mode: Password Setup -->
|
||||
<template v-else-if="isSetupMode && !isSetup">
|
||||
<div class="mb-4 p-4 bg-white/5 border border-white/10 rounded-lg text-white/80 text-sm">
|
||||
<p class="mb-2">Create a password to secure your Archipelago node.</p>
|
||||
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="setup-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||
@input="error = null"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="setup-confirm-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.confirmPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
ref="confirmPasswordInputRef"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keydown.enter="handleSetupWithSound"
|
||||
@input="error = null"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="{ 'opacity-60': !password || password.length < 8 || password !== confirmPassword }"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.settingUp') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- TOTP Verification Step -->
|
||||
<template v-else-if="requiresTotp">
|
||||
<div class="mb-6 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p class="text-white/80 text-sm mb-1">{{ t('login.twoFactorTitle') }}</p>
|
||||
<p class="text-white/50 text-xs">{{ t('login.totpInstruction') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
ref="totpInputRef"
|
||||
v-model="totpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
data-controller-no-submit
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@keyup.enter="handleTotpVerify"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleTotpVerify"
|
||||
:disabled="loading || !totpCode"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.verifyButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.verifying') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="useBackupCode = !useBackupCode; totpCode = ''"
|
||||
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
|
||||
>
|
||||
{{ useBackupCode ? t('login.useAuthCode') : t('login.useBackupCode') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Normal Login Mode -->
|
||||
<template v-else>
|
||||
<!-- Demo credential hint -->
|
||||
<div v-if="isDemo" class="mb-4 p-3 bg-orange-500/15 border border-orange-400/30 rounded-lg text-orange-100 text-sm text-center">
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All auth inputs opt out of controller-nav's Enter→click-next-button
|
||||
pattern (data-controller-no-submit): they submit via their own Enter
|
||||
handlers, and while the submit button is still disabled the "next
|
||||
focusable" is Replay Intro — the companion's auto-login injects
|
||||
Enter before Vue re-enables the button, which replayed the intro
|
||||
in a loop on every app connect. -->
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
data-form-type="other"
|
||||
data-controller-no-submit
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keydown.enter="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">{{ t('login.loginButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('login.loggingIn') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-6 text-center text-sm text-white/40">
|
||||
{{ t('login.recoveryNote') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay Intro / Restart Onboarding - Bottom of Page -->
|
||||
<div class="mt-8 text-center flex items-center justify-center gap-4">
|
||||
<button
|
||||
@click="replayIntro"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<template v-if="!isDemo">
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="confirmingRestartOnboarding ? 'text-orange-400 hover:text-orange-300' : 'text-white/50 hover:text-white/70'"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : (confirmingRestartOnboarding ? t('login.restartConfirm') : t('login.onboarding')) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isLocalRedirect } from '../router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
import { IS_DEMO, DEMO_PASSWORD, clearDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const currentRoute = useRoute()
|
||||
|
||||
/** After login, redirect to the intended page or default to home */
|
||||
const loginRedirectTo = computed(() => {
|
||||
const redirect = currentRoute.query.redirect as string
|
||||
if (redirect && isLocalRedirect(redirect)) return redirect
|
||||
return '/dashboard'
|
||||
})
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const isDemo = IS_DEMO
|
||||
const password = ref(IS_DEMO ? DEMO_PASSWORD : '')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSetup = ref(false)
|
||||
const whooshAway = ref(false)
|
||||
const requiresTotp = ref(false)
|
||||
const totpCode = ref('')
|
||||
const useBackupCode = ref(false)
|
||||
const totpInputRef = ref<HTMLInputElement | null>(null)
|
||||
const confirmPasswordInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Server startup state
|
||||
const serverReady = ref(false)
|
||||
const serverChecking = ref(true)
|
||||
const startupProgress = ref(0)
|
||||
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Whether we're in setup mode (no password created yet)
|
||||
const isSetupMode = ref(false)
|
||||
|
||||
// Whether we're still checking the setup state (prevents flash of wrong form)
|
||||
const isCheckingSetup = ref(true)
|
||||
|
||||
// Whether the login form should be disabled (server not ready)
|
||||
const formDisabled = computed(() => !serverReady.value)
|
||||
|
||||
async function checkServerHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
// Any HTTP response from backend (200, 401, 403, etc.) means it's up
|
||||
// Only 502/503 from nginx means backend isn't running yet
|
||||
return response.status !== 502 && response.status !== 503
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function pollServerStartup(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Animate progress slowly while waiting
|
||||
startupProgressInterval = setInterval(() => {
|
||||
if (startupProgress.value < 90) {
|
||||
startupProgress.value += Math.random() * 8 + 2
|
||||
if (startupProgress.value > 90) startupProgress.value = 90
|
||||
}
|
||||
}, 600)
|
||||
|
||||
const poll = async () => {
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
startupProgress.value = 100
|
||||
// Brief pause to show 100% before revealing form
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Retry in 2s
|
||||
startupPollTimer = setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
let unlockHandler: (() => void) | null = null
|
||||
|
||||
function removeUnlockListeners() {
|
||||
if (unlockHandler) {
|
||||
document.removeEventListener('click', unlockHandler)
|
||||
document.removeEventListener('touchstart', unlockHandler)
|
||||
document.removeEventListener('keydown', unlockHandler)
|
||||
unlockHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeUnlockListeners()
|
||||
if (startupPollTimer) clearTimeout(startupPollTimer)
|
||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
|
||||
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
|
||||
unlockHandler = () => {
|
||||
if (!fromSplash) {
|
||||
resumeAudioContext()
|
||||
startSynthwave()
|
||||
}
|
||||
removeUnlockListeners()
|
||||
}
|
||||
document.addEventListener('click', unlockHandler, { once: true })
|
||||
document.addEventListener('touchstart', unlockHandler, { once: true })
|
||||
document.addEventListener('keydown', unlockHandler, { once: true })
|
||||
|
||||
// Check server health first
|
||||
const healthy = await checkServerHealth()
|
||||
if (healthy) {
|
||||
serverReady.value = true
|
||||
serverChecking.value = false
|
||||
} else {
|
||||
// Server not ready — start polling with progress bar
|
||||
await pollServerStartup()
|
||||
}
|
||||
|
||||
// Check if password has been set up — show setup form if not
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
isSetup.value = Boolean(result)
|
||||
isSetupMode.value = !isSetup.value
|
||||
} catch {
|
||||
isSetup.value = false
|
||||
isSetupMode.value = true
|
||||
} finally {
|
||||
isCheckingSetup.value = false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function handleSetupWithSound() {
|
||||
if (!loading.value && password.value && password.value === confirmPassword.value) {
|
||||
playPop()
|
||||
}
|
||||
handleSetup()
|
||||
}
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = t('login.errorMinLength')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = t('login.errorMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'auth.setup',
|
||||
params: { password: password.value.trim() }
|
||||
})
|
||||
|
||||
await store.login(password.value.trim())
|
||||
// Verify session cookie works before navigating (prevents connection lost on first login)
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
|
||||
} catch {
|
||||
error.value = 'Setup succeeded but session could not be established. Try refreshing.'
|
||||
store.logout()
|
||||
return
|
||||
}
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
// First password setup counts as the first login (video → static rotation).
|
||||
try { localStorage.setItem('neode_first_login_done', '1') } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || t('login.errorSetupFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoginWithSound() {
|
||||
if (!loading.value && password.value) {
|
||||
playPop()
|
||||
}
|
||||
handleLogin()
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await store.login(password.value.trim())
|
||||
if (result?.requires_totp) {
|
||||
requiresTotp.value = true
|
||||
loading.value = false
|
||||
// Focus the TOTP input after DOM update
|
||||
setTimeout(() => totpInputRef.value?.focus(), 100)
|
||||
return
|
||||
}
|
||||
// Verify session cookie works before navigating (prevents login loop on LAN)
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.echo', params: { message: 'session-check' } })
|
||||
} catch {
|
||||
error.value = 'Login succeeded but session could not be established. Try clearing cookies and refreshing.'
|
||||
store.logout()
|
||||
return
|
||||
}
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || t('login.errorLoginFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!totpCode.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (useBackupCode.value) {
|
||||
await rpcClient.loginBackup(totpCode.value)
|
||||
} else {
|
||||
await rpcClient.loginTotp(totpCode.value)
|
||||
}
|
||||
await store.completeLoginAfterTotp()
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
consumeOnboardingFinale()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 520))
|
||||
await router.replace(loginRedirectTo.value).catch(() => {
|
||||
window.location.href = loginRedirectTo.value
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/expired|too many/i.test(msg)) {
|
||||
// Session expired, go back to password step
|
||||
requiresTotp.value = false
|
||||
totpCode.value = ''
|
||||
error.value = msg
|
||||
} else {
|
||||
error.value = msg || t('login.errorInvalidCode')
|
||||
}
|
||||
totpCode.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
||||
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
||||
* regular password login (e.g. the demo). Subsequent logins stay
|
||||
* deliberately low-key (justLoggedIn only).
|
||||
* Also stamps neode_first_login_done: the login page keeps the intro VIDEO
|
||||
* background until someone has logged in once (OnboardingWrapper reads it
|
||||
* to pick video vs the rotating static backgrounds). */
|
||||
function consumeOnboardingFinale() {
|
||||
try {
|
||||
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
||||
sessionStorage.removeItem('archy_onboarding_finale')
|
||||
loginTransition.setJustCompletedOnboarding(true)
|
||||
}
|
||||
localStorage.setItem('neode_first_login_done', '1')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Demo: also clear the per-day gate so the intro plays again now.
|
||||
if (IS_DEMO) clearDemoIntroSeen()
|
||||
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
|
||||
// what keeps fresh browsers on already-onboarded nodes from replaying it) —
|
||||
// this explicit one-shot flag tells it the replay is deliberate.
|
||||
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
|
||||
// Navigate to root to trigger splash screen
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const isResettingOnboarding = ref(false)
|
||||
const confirmingRestartOnboarding = ref(false)
|
||||
let confirmRestartTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function restartOnboarding() {
|
||||
if (isResettingOnboarding.value) return
|
||||
// First click arms a confirmation state; only a second explicit click restarts.
|
||||
if (!confirmingRestartOnboarding.value) {
|
||||
confirmingRestartOnboarding.value = true
|
||||
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||
confirmRestartTimer = setTimeout(() => {
|
||||
confirmingRestartOnboarding.value = false
|
||||
confirmRestartTimer = null
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
if (confirmRestartTimer) {
|
||||
clearTimeout(confirmRestartTimer)
|
||||
confirmRestartTimer = null
|
||||
}
|
||||
confirmingRestartOnboarding.value = false
|
||||
isResettingOnboarding.value = true
|
||||
// Local-only reset — no RPC needed since user isn't logged in.
|
||||
// Onboarding pages are all public, so clearing localStorage is enough.
|
||||
localStorage.removeItem('neode_onboarding_complete')
|
||||
localStorage.removeItem('neode_did')
|
||||
localStorage.removeItem('neode_did_state')
|
||||
localStorage.removeItem('neode_backup_created')
|
||||
router.push('/onboarding/intro').then(() => {
|
||||
window.location.reload()
|
||||
}).catch(() => {
|
||||
window.location.href = '/onboarding/intro'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Server startup progress bar */
|
||||
.startup-progress-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.startup-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease-out;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
/* Perspective for 3D fly effect */
|
||||
.login-fly-perspective {
|
||||
perspective: 1200px;
|
||||
perspective-origin: center center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
overflow: visible !important;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Fly towards user - card zooms forward as it transitions out */
|
||||
.login-fly-towards {
|
||||
animation: login-fly-towards 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes login-fly-towards {
|
||||
0% {
|
||||
transform: translateZ(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
60% {
|
||||
transform: translateZ(180px) scale(1.4);
|
||||
opacity: 0.95;
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateZ(400px) scale(2);
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,592 @@
|
||||
<template>
|
||||
<div class="marketplace-container">
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div ref="marketplaceHeaderRef" class="app-header-desktop mb-4 items-center gap-4 relative">
|
||||
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
|
||||
>
|
||||
{{ section.name }}
|
||||
<span v-if="section.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="marketplace-category-select">App Store category</label>
|
||||
<select
|
||||
id="marketplace-category-select"
|
||||
:value="selectedCategory"
|
||||
class="segmented-select-control"
|
||||
@change="selectAppStoreSection(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}{{ section.id === 'nostr' && nostrApps.length > 0 ? ` +${nostrApps.length}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="marketplaceCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
|
||||
<div class="app-header-mobile mb-4">
|
||||
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === section.id }"
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Apps Section -->
|
||||
<div class="pb-8">
|
||||
<!-- Community Load Error -->
|
||||
<div v-if="communityError" class="alert-error mb-4">
|
||||
{{ communityError }}
|
||||
<button @click="loadCommunityMarketplace()" class="ml-2 underline hover:no-underline">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template v-if="(loadingCommunity || nostrLoading) && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-6 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<MarketplaceAppCard
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:index="index"
|
||||
:stagger="showStagger"
|
||||
:installed="isInstalled(app.id)"
|
||||
:installing="installingApps.has(app.id)"
|
||||
:install-progress="installingApps.get(app.id)"
|
||||
:installed-state="getInstalledState(app.id)"
|
||||
:starting-up="isStartingUp(app.id)"
|
||||
:containers-scanned="containersScanned"
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@launch="launchInstalledApp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
|
||||
<div v-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
INSTALLED_ALIASES,
|
||||
getAppTier,
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
} from './marketplace/marketplaceData'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const server = useServerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showStagger = !marketplaceAnimationDone
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Category state — read initial value from query param (set by Discover page navigation)
|
||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||
// so it works regardless of which page is active
|
||||
|
||||
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
|
||||
function selectCategory(id: string) {
|
||||
selectedCategory.value = id
|
||||
const query = id === 'all' ? {} : { category: id }
|
||||
router.replace({ name: 'marketplace', query }).catch(() => {})
|
||||
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAppStoreSection(id: string) {
|
||||
if (id === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
selectCategory(id)
|
||||
}
|
||||
|
||||
watch(() => route.query.category, (category) => {
|
||||
const next = typeof category === 'string' && category ? category : 'all'
|
||||
selectedCategory.value = next
|
||||
if (next === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
})
|
||||
|
||||
// Community marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
|
||||
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
|
||||
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
marketplaceHeaderRef,
|
||||
marketplacePrimaryRef,
|
||||
marketplaceCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
// Nostr community marketplace state
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
const nostrLoading = ref(false)
|
||||
const nostrError = ref('')
|
||||
|
||||
async function loadNostrMarketplace() {
|
||||
if (nostrApps.value.length > 0 || nostrLoading.value) return
|
||||
nostrLoading.value = true
|
||||
nostrError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.marketplaceDiscover()
|
||||
nostrApps.value = res.apps.map(app => ({
|
||||
id: app.manifest.app_id,
|
||||
title: app.manifest.name,
|
||||
version: app.manifest.version,
|
||||
description: typeof app.manifest.description === 'string'
|
||||
? app.manifest.description
|
||||
: app.manifest.description,
|
||||
icon: app.manifest.icon_url || '',
|
||||
author: app.manifest.author.name,
|
||||
dockerImage: app.manifest.container.image,
|
||||
repoUrl: app.manifest.repo_url,
|
||||
category: app.manifest.category,
|
||||
source: 'nostr',
|
||||
trustScore: app.trust_score,
|
||||
trustTier: app.trust_tier,
|
||||
relayCount: app.relay_count,
|
||||
}))
|
||||
} catch (e) {
|
||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
if (import.meta.env.DEV) console.warn('Nostr marketplace discovery failed:', e)
|
||||
} finally {
|
||||
nostrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const installedPackages = computed(() => {
|
||||
return store.data?.['package-data'] || {}
|
||||
})
|
||||
|
||||
const containersScannedRaw = computed(() => {
|
||||
return store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false
|
||||
})
|
||||
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||
// timeout, treat the scan as done so cards render their normal install state.
|
||||
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||
containersScannedRaw,
|
||||
computed(() => store.hasLoadedInitialData),
|
||||
)
|
||||
|
||||
// Combine curated apps with Nostr relay-discovered apps
|
||||
const allApps = computed(() => {
|
||||
const local: (MarketplaceApp & { category: string; source: string })[] = []
|
||||
|
||||
const community = communityApps.value.map(app => {
|
||||
const category = categorizeCommunityApp(app)
|
||||
return { ...app, category, source: 'community' }
|
||||
})
|
||||
|
||||
const base = [...local, ...community]
|
||||
|
||||
if (nostrApps.value.length > 0) {
|
||||
const existingIds = new Set(base.map(a => a.id))
|
||||
const nostrMerged = nostrApps.value
|
||||
.filter(app => !existingIds.has(app.id))
|
||||
.map(app => {
|
||||
const category = app.category || categorizeCommunityApp(app)
|
||||
return { ...app, category, source: 'nostr' }
|
||||
})
|
||||
return [...base, ...nostrMerged]
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
app.title?.toLowerCase().includes(query) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(query)) ||
|
||||
(typeof app.description === 'object' && app.description?.short?.toLowerCase().includes(query)) ||
|
||||
app.id?.toLowerCase().includes(query) ||
|
||||
app.author?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Hide installed, installing, and web-only apps (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id) && app.dockerImage)
|
||||
|
||||
return apps
|
||||
})
|
||||
|
||||
function isInstalled(appId: string): boolean {
|
||||
if (appId in installedPackages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
||||
}
|
||||
|
||||
function getInstalledState(appId: string): string | null {
|
||||
const pkg = installedPackages.value[appId]
|
||||
if (pkg) return pkg.state
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
if (aliases) {
|
||||
for (const a of aliases) {
|
||||
const aliasPkg = installedPackages.value[a]
|
||||
if (aliasPkg) return aliasPkg.state
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isStartingUp(appId: string): boolean {
|
||||
const state = getInstalledState(appId)
|
||||
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
marketplaceAnimationDone = true
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
|
||||
communityApps.value = getCuratedAppList()
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] Navigating to app detail:', app)
|
||||
|
||||
try {
|
||||
if (isInstalled(app.id)) {
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] App is installed, navigating to app details page')
|
||||
router.push({ name: 'app-details', params: { id: app.id } })
|
||||
} else {
|
||||
setCurrentApp(app)
|
||||
if (import.meta.env.DEV) console.log('[Marketplace] App data stored in composable')
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id } })
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[Marketplace] Navigation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function trackTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
const idx = activeTimers.indexOf(id)
|
||||
if (idx !== -1) activeTimers.splice(idx, 1)
|
||||
fn()
|
||||
}, ms)
|
||||
activeTimers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of activeTimers) clearTimeout(t)
|
||||
activeTimers.length = 0
|
||||
})
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
server.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Queued…',
|
||||
attempt: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
const message = "Failed: " + (err instanceof Error ? err.message : String(err))
|
||||
server.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
title: app.title ?? app.id,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message,
|
||||
attempt: 0,
|
||||
})
|
||||
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, url: installUrl, version: app.version },
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar styling for apps section */
|
||||
.marketplace-container ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.marketplace-container ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.marketplace-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 255px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,700 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- App Details -->
|
||||
<div v-else-if="app" key="content">
|
||||
<!-- Compact Hero Section -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info (grows to fill space) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-sm mb-2">{{ shortDescription }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<select
|
||||
v-if="!isInstalled && installVersions.length > 1"
|
||||
v-model="selectedInstallVersion"
|
||||
:disabled="installing"
|
||||
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||
class="rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||
>
|
||||
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="!isInstalled"
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Top: Icon + Info -->
|
||||
<div class="grid grid-cols-[80px_1fr] gap-4 mb-4">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ shortDescription }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install-time version selector (multi-version apps) -->
|
||||
<select
|
||||
v-if="!isInstalled && installVersions.length > 1"
|
||||
v-model="selectedInstallVersion"
|
||||
:disabled="installing"
|
||||
:aria-label="t('marketplaceDetails.selectVersion')"
|
||||
class="w-full mb-2 rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
||||
>
|
||||
<option v-for="v in installVersions" :key="v.version" :value="v.version">
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Bottom: Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ demoNoInstall ? 'Not available in demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Mobile) -->
|
||||
<div v-if="installError" class="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Desktop) -->
|
||||
<div v-if="installError" class="hidden md:block mt-4 p-4 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
|
||||
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
|
||||
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ longDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
|
||||
</div>
|
||||
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ app.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
|
||||
</div>
|
||||
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Package</span>
|
||||
<span class="text-white font-medium text-xs">.s9pk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- App Dependencies -->
|
||||
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
|
||||
<div
|
||||
v-for="dep in dependencies"
|
||||
:key="dep.id"
|
||||
class="flex items-center gap-3 py-2 border-b border-white/10"
|
||||
>
|
||||
<!-- Status indicator -->
|
||||
<svg v-if="dep.status === 'running'" class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="dep.status === 'stopped'" class="w-5 h-5 text-yellow-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
|
||||
<p class="text-white/50 text-xs">
|
||||
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Install missing dependencies button -->
|
||||
<button
|
||||
v-if="dependencies.some(d => d.status === 'missing')"
|
||||
@click="installDependencies"
|
||||
:disabled="installingDeps"
|
||||
class="glass-button w-full mt-3 px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg v-if="installingDeps" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="py-2 border-b border-white/10">
|
||||
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card (no GitHub - repo link removed per product) -->
|
||||
<div v-if="app.manifestUrl" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
:href="app.manifestUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ t('marketplaceDetails.downloadPackage') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else key="not-found" class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { handleImageError } from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const app = ref<MarketplaceAppInfo | null>(null)
|
||||
const installing = ref(false)
|
||||
const installingDeps = ref(false)
|
||||
const installError = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const bitcoinPruned = ref(false)
|
||||
|
||||
// Multi-version support: install-time version choice. Populated from the signed
|
||||
// catalog for apps that offer multiple versions (e.g. Bitcoin Core / Knots).
|
||||
// Hidden when an app offers only one version — install stays one-click.
|
||||
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||
const selectedInstallVersion = ref('')
|
||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Web-only apps (no container, just a URL) — always treated as "installed"
|
||||
const isWebOnly = computed(() => {
|
||||
return !!(app.value?.webUrl && !app.value?.dockerImage)
|
||||
})
|
||||
|
||||
// Check if app is already installed
|
||||
const isInstalled = computed(() => {
|
||||
if (isWebOnly.value) return true
|
||||
return !!store.packages[appId.value]
|
||||
})
|
||||
|
||||
// Extract descriptions with safety checks
|
||||
const shortDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.short || desc.long || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const longDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.long || desc.short || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
|
||||
|
||||
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
|
||||
// Placeholder features
|
||||
const features = computed(() => {
|
||||
return [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default',
|
||||
'Open source'
|
||||
]
|
||||
})
|
||||
|
||||
/** App dependency definitions */
|
||||
const R = '146.59.87.168:3000/lfg2025'
|
||||
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
|
||||
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
'mempool': [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` },
|
||||
{ id: 'electrumx', title: 'ElectrumX', dockerImage: `${R}/electrumx:v1.18.0` },
|
||||
],
|
||||
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
|
||||
}
|
||||
|
||||
/** Check dependency status against installed packages */
|
||||
const dependencies = computed(() => {
|
||||
if (!app.value) return []
|
||||
const deps = APP_DEPENDENCIES[app.value.id]
|
||||
if (!deps) return []
|
||||
return deps.map(dep => {
|
||||
const pkg = store.packages[dep.id]
|
||||
let status: 'running' | 'stopped' | 'missing' = 'missing'
|
||||
if (pkg) {
|
||||
status = pkg.state === 'running' ? 'running' : 'stopped'
|
||||
}
|
||||
return { ...dep, status }
|
||||
})
|
||||
})
|
||||
|
||||
const installBlockedReason = computed(() => {
|
||||
const id = app.value?.id
|
||||
if (!bitcoinPruned.value || !id) return ''
|
||||
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
// Demo: only demoable apps can be installed; the rest show "Not available in demo".
|
||||
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (import.meta.env.DEV) console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
|
||||
|
||||
try {
|
||||
const loadedApp = getCurrentApp()
|
||||
|
||||
if (loadedApp && loadedApp.id === appId.value) {
|
||||
app.value = loadedApp
|
||||
loading.value = false
|
||||
} else {
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
void loadInstallVersions()
|
||||
})
|
||||
|
||||
// Fetch the catalog's selectable versions so the install panel can offer a
|
||||
// choice (latest pre-selected). Best-effort: on any failure the selector stays
|
||||
// hidden and install proceeds at the catalog default.
|
||||
async function loadInstallVersions() {
|
||||
installVersions.value = []
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(appId.value)
|
||||
if (!info.supportsVersions || info.versions.length < 2) return
|
||||
installVersions.value = info.versions
|
||||
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
if (route.query.from === 'home') {
|
||||
router.push('/dashboard').catch(() => {})
|
||||
} else if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
} else {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function goToInstalledApp() {
|
||||
// Web-only apps: launch directly via appLauncher
|
||||
if (isWebOnly.value && app.value?.webUrl) {
|
||||
useAppLauncherStore().open({
|
||||
url: app.value.webUrl,
|
||||
title: app.value.title || appId.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: `/dashboard/apps/${appId.value}`,
|
||||
query: { from: 'marketplace' }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function installDependencies() {
|
||||
if (installingDeps.value) return
|
||||
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
|
||||
if (!missingDeps.length) return
|
||||
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
|
||||
installError.value = electrumxArchiveWarning
|
||||
toast.error(electrumxArchiveWarning)
|
||||
return
|
||||
}
|
||||
|
||||
installingDeps.value = true
|
||||
installError.value = null
|
||||
|
||||
try {
|
||||
// Install dependencies sequentially (order matters: bitcoin before electrumx)
|
||||
for (const dep of missingDeps) {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: dep.id,
|
||||
dockerImage: dep.dockerImage,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
// Wait for package to register before installing next
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
} finally {
|
||||
installingDeps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (installBlockedReason.value) {
|
||||
installError.value = installBlockedReason.value
|
||||
toast.error(installBlockedReason.value)
|
||||
return
|
||||
}
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
installError.value = null
|
||||
|
||||
// Multi-version: a runner-chosen version (when offered) overrides the default.
|
||||
const chosenVersion = (installVersions.value.length > 1 && selectedInstallVersion.value)
|
||||
? selectedInstallVersion.value
|
||||
: app.value.version
|
||||
|
||||
try {
|
||||
if (app.value.dockerImage) {
|
||||
// Docker-based app installation
|
||||
const installParams: Record<string, unknown> = {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: chosenVersion,
|
||||
}
|
||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: installParams,
|
||||
timeout: 600000,
|
||||
})
|
||||
} else {
|
||||
// Package-based installation
|
||||
const installUrl = app.value.url || app.value.manifestUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: chosenVersion,
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait a moment for the package to be registered
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-6">
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="router.push(backTarget)" />
|
||||
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('monitoring.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('monitoring.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('csv')">
|
||||
{{ t('monitoring.exportCsv') }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('json')">
|
||||
{{ t('monitoring.exportJson') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ liveSystem.cpu_percent.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ liveSystem.load_avg_1.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.mem_used_bytes) }} / {{ formatBytes(liveSystem.mem_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(liveSystem.net_rx_bytes) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(liveSystem.net_tx_bytes) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.cpuUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="cpuDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.memoryUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="memDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.networkIo') }}</h3>
|
||||
<LineChart
|
||||
:datasets="netDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.rpcLatency') }}</h3>
|
||||
<LineChart
|
||||
:datasets="latencyDatasets"
|
||||
:labels="timeLabels"
|
||||
:width="chartWidth"
|
||||
:height="180"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert History -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.alertHistory') }}</h3>
|
||||
<button
|
||||
class="glass-button text-xs px-3 py-1"
|
||||
@click="showAlertConfig = !showAlertConfig"
|
||||
>
|
||||
{{ showAlertConfig ? t('monitoring.hideConfig') : t('common.configure') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alert Rule Configuration -->
|
||||
<div v-if="showAlertConfig" class="mb-4 space-y-2">
|
||||
<div
|
||||
v-for="rule in alertRules"
|
||||
:key="rule.kind"
|
||||
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<ToggleSwitch :model-value="rule.enabled" @update:model-value="toggleAlertRule(rule.kind, $event)" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white">{{ ruleLabel(rule.kind) }}</p>
|
||||
<p class="text-xs text-white/40">{{ rule.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
:value="rule.threshold"
|
||||
class="monitoring-threshold-input"
|
||||
@change="updateThreshold(rule.kind, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span class="text-xs text-white/40">{{ ruleUnit(rule.kind) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fired Alerts List -->
|
||||
<div v-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
|
||||
{{ t('monitoring.noAlerts') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg"
|
||||
:class="{ 'opacity-50': alert.acknowledged }"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||
:class="alertDotColor(alert.kind)"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white">{{ alert.message }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatAlertTime(alert.timestamp) }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!alert.acknowledged"
|
||||
class="text-xs text-white/40 hover:text-white/70 flex-shrink-0"
|
||||
@click="acknowledgeAlert(alert.id)"
|
||||
>
|
||||
{{ t('common.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Resource Breakdown -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">{{ t('monitoring.containerResources') }}</h3>
|
||||
<div v-if="!containers.length" class="text-white/40 text-sm py-4 text-center">
|
||||
{{ t('monitoring.noContainerMetrics') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="c in containers"
|
||||
:key="c.name"
|
||||
class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white truncate">{{ c.name }}</p>
|
||||
<div class="flex gap-4 mt-1 text-xs text-white/50">
|
||||
<span>CPU: {{ c.cpu_percent.toFixed(1) }}%</span>
|
||||
<span>Mem: {{ formatBytes(c.mem_used_bytes) }}</span>
|
||||
<span>Net: {{ formatBytes(c.net_rx_bytes) }} / {{ formatBytes(c.net_tx_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="monitoring-bar-container">
|
||||
<div
|
||||
class="monitoring-bar-fill"
|
||||
:style="{ width: Math.min(c.cpu_percent, 100) + '%' }"
|
||||
:class="c.cpu_percent > 80 ? 'monitoring-bar-danger' : c.cpu_percent > 50 ? 'monitoring-bar-warn' : 'monitoring-bar-ok'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Timeline -->
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.systemHealth') }}</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> {{ t('common.healthy') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> {{ t('common.elevated') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> {{ t('common.critical') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-0.5 h-6">
|
||||
<div
|
||||
v-for="(entry, idx) in healthTimeline"
|
||||
:key="idx"
|
||||
class="flex-1 rounded-sm transition-colors"
|
||||
:class="healthColor(entry)"
|
||||
:title="entry.label"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1 text-xs text-white/30">
|
||||
<span>{{ historyMinutesAgo }}m ago</span>
|
||||
<span>Now</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
{{ t('monitoring.refreshFooter') }} · {{ t('monitoring.wsConnections', { count: current?.ws_connections ?? 0 }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
net_rx_bytes: number
|
||||
net_tx_bytes: number
|
||||
load_avg_1: number
|
||||
load_avg_5: number
|
||||
load_avg_15: number
|
||||
}
|
||||
|
||||
interface ContainerMetrics {
|
||||
name: string
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_limit_bytes: number
|
||||
net_rx_bytes: number
|
||||
net_tx_bytes: number
|
||||
block_read_bytes: number
|
||||
block_write_bytes: number
|
||||
}
|
||||
|
||||
interface MetricSnapshot {
|
||||
timestamp: number
|
||||
system: SystemMetrics
|
||||
containers: ContainerMetrics[]
|
||||
rpc_latency_ms: number
|
||||
ws_connections: number
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
resolution: string
|
||||
count: number
|
||||
data: MetricSnapshot[]
|
||||
}
|
||||
|
||||
interface AlertRule {
|
||||
kind: string
|
||||
threshold: number
|
||||
enabled: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FiredAlert {
|
||||
id: string
|
||||
kind: string
|
||||
message: string
|
||||
value: number
|
||||
threshold: number
|
||||
timestamp: number
|
||||
acknowledged: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Monitoring is reachable from Web5 (the monitoring link) AND from the Home
|
||||
// "System" card (which passes ?from=home). The back button follows the entry
|
||||
// point so it returns where the user came from instead of always Web5.
|
||||
const cameFromHome = computed(() => route.query.from === 'home')
|
||||
const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboard/web5'))
|
||||
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
const containers = ref<ContainerMetrics[]>([])
|
||||
const alerts = ref<FiredAlert[]>([])
|
||||
const alertRules = ref<AlertRule[]>([])
|
||||
const showAlertConfig = ref(false)
|
||||
const chartWidth = ref(380)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const liveSystem = computed<SystemMetrics>(() => ({
|
||||
cpu_percent: homeStatus.stats.cpuPercent,
|
||||
mem_used_bytes: homeStatus.stats.memUsed,
|
||||
mem_total_bytes: homeStatus.stats.memTotal,
|
||||
disk_used_bytes: homeStatus.stats.diskUsed,
|
||||
disk_total_bytes: homeStatus.stats.diskTotal,
|
||||
net_rx_bytes: current.value?.system.net_rx_bytes ?? 0,
|
||||
net_tx_bytes: current.value?.system.net_tx_bytes ?? 0,
|
||||
load_avg_1: homeStatus.stats.loadAvg1,
|
||||
load_avg_5: homeStatus.stats.loadAvg5,
|
||||
load_avg_15: homeStatus.stats.loadAvg15,
|
||||
}))
|
||||
|
||||
const memPercent = computed(() => {
|
||||
if (!liveSystem.value.mem_total_bytes) return '--'
|
||||
return ((liveSystem.value.mem_used_bytes / liveSystem.value.mem_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const diskPercent = computed(() => {
|
||||
if (!liveSystem.value.disk_total_bytes) return '--'
|
||||
return ((liveSystem.value.disk_used_bytes / liveSystem.value.disk_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const historyMinutesAgo = computed(() => history.value.length || 60)
|
||||
|
||||
const timeLabels = computed(() => {
|
||||
if (!history.value.length) return []
|
||||
return history.value.map((s) => {
|
||||
const d = new Date(s.timestamp * 1000)
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
})
|
||||
})
|
||||
|
||||
const cpuDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'CPU',
|
||||
data: history.value.map((s) => s.system.cpu_percent),
|
||||
color: '#fb923c',
|
||||
}])
|
||||
|
||||
const memDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'Memory',
|
||||
data: history.value.map((s) =>
|
||||
s.system.mem_total_bytes > 0
|
||||
? (s.system.mem_used_bytes / s.system.mem_total_bytes) * 100
|
||||
: 0,
|
||||
),
|
||||
color: '#3b82f6',
|
||||
}])
|
||||
|
||||
const netDatasets = computed<ChartDataset[]>(() => [
|
||||
{
|
||||
label: 'RX',
|
||||
data: history.value.map((s) => s.system.net_rx_bytes),
|
||||
color: '#4ade80',
|
||||
},
|
||||
{
|
||||
label: 'TX',
|
||||
data: history.value.map((s) => s.system.net_tx_bytes),
|
||||
color: '#f59e0b',
|
||||
},
|
||||
])
|
||||
|
||||
const latencyDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'Latency',
|
||||
data: history.value.map((s) => s.rpc_latency_ms),
|
||||
color: '#a78bfa',
|
||||
}])
|
||||
|
||||
interface HealthEntry {
|
||||
cpu: number
|
||||
mem: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const healthTimeline = computed<HealthEntry[]>(() => {
|
||||
if (!history.value.length) {
|
||||
return Array.from({ length: 60 }, () => ({ cpu: 0, mem: 0, label: 'No data' }))
|
||||
}
|
||||
return history.value.map((s) => {
|
||||
const memPct = s.system.mem_total_bytes > 0
|
||||
? (s.system.mem_used_bytes / s.system.mem_total_bytes) * 100
|
||||
: 0
|
||||
const d = new Date(s.timestamp * 1000)
|
||||
const time = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
return {
|
||||
cpu: s.system.cpu_percent,
|
||||
mem: memPct,
|
||||
label: `${time} — CPU: ${s.system.cpu_percent.toFixed(1)}%, Mem: ${memPct.toFixed(1)}%`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function healthColor(entry: HealthEntry): string {
|
||||
if (entry.cpu > 90 || entry.mem > 90) return 'bg-red-400/60'
|
||||
if (entry.cpu > 70 || entry.mem > 70) return 'bg-orange-400/40'
|
||||
return 'bg-green-400/30'
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
function ruleLabel(kind: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
disk_usage: t('monitoring.diskUsage'),
|
||||
ram_usage: t('monitoring.ramUsage'),
|
||||
container_crash: t('monitoring.containerCrash'),
|
||||
backend_error_spike: t('monitoring.rpcLatencySpike'),
|
||||
ssl_cert_expiry: t('monitoring.sslCertExpiry'),
|
||||
}
|
||||
return labels[kind] ?? kind
|
||||
}
|
||||
|
||||
function ruleUnit(kind: string): string {
|
||||
const units: Record<string, string> = {
|
||||
disk_usage: '%',
|
||||
ram_usage: '%',
|
||||
container_crash: '',
|
||||
backend_error_spike: 'ms',
|
||||
ssl_cert_expiry: 'days',
|
||||
}
|
||||
return units[kind] ?? ''
|
||||
}
|
||||
|
||||
function alertDotColor(kind: string): string {
|
||||
if (kind === 'container_crash' || kind === 'ssl_cert_expiry') return 'bg-red-400'
|
||||
if (kind === 'disk_usage' || kind === 'ram_usage') return 'bg-orange-400'
|
||||
return 'bg-yellow-400'
|
||||
}
|
||||
|
||||
function formatAlertTime(timestamp: number): string {
|
||||
const d = new Date(timestamp * 1000)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
async function exportMetrics(format: 'csv' | 'json') {
|
||||
try {
|
||||
const data = await rpcClient.call<{ csv?: string; data?: unknown[]; count: number }>({
|
||||
method: 'monitoring.export',
|
||||
params: { format, resolution: 'minute', count: 1440 },
|
||||
})
|
||||
let blob: Blob
|
||||
let filename: string
|
||||
if (format === 'csv' && data?.csv) {
|
||||
blob = new Blob([data.csv], { type: 'text/csv' })
|
||||
filename = `archipelago-metrics-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
} else {
|
||||
blob = new Blob([JSON.stringify(data?.data ?? [], null, 2)], { type: 'application/json' })
|
||||
filename = `archipelago-metrics-${new Date().toISOString().slice(0, 10)}.json`
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
if (import.meta.env.DEV) console.warn('Failed to export metrics')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrent() {
|
||||
try {
|
||||
await homeStatus.refreshSystemStats()
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current',
|
||||
})
|
||||
if (data && 'system' in data) {
|
||||
current.value = data
|
||||
containers.value = data.containers ?? []
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const data = await rpcClient.call<HistoryResponse>({
|
||||
method: 'monitoring.history',
|
||||
params: { resolution: 'minute', count: 60 },
|
||||
})
|
||||
if (data?.data) {
|
||||
history.value = data.data
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlerts() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
|
||||
method: 'monitoring.alerts',
|
||||
params: { count: 50 },
|
||||
})
|
||||
if (data?.alerts) {
|
||||
alerts.value = data.alerts.reverse()
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlertRules() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ rules: AlertRule[] }>({
|
||||
method: 'monitoring.alert-rules',
|
||||
})
|
||||
if (data?.rules) {
|
||||
alertRules.value = data.rules
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAlertRule(kind: string, enabled: boolean) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
|
||||
await fetchAlertRules()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function updateThreshold(kind: string, value: string) {
|
||||
const threshold = parseFloat(value)
|
||||
if (isNaN(threshold) || threshold <= 0) return
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
|
||||
await fetchAlertRules()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeAlert(id: string) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
|
||||
await fetchAlerts()
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function updateChartWidth() {
|
||||
const container = document.querySelector('.glass-card')
|
||||
if (container) {
|
||||
chartWidth.value = Math.max(container.clientWidth - 40, 200)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
|
||||
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()])
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()])
|
||||
} catch {
|
||||
// Background poll — ignore transient errors
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="glass-card px-8 py-10 text-center max-w-md">
|
||||
<h1 class="text-6xl font-bold text-white/30 mb-4">404</h1>
|
||||
<p class="text-lg text-white/70 mb-6">Page not found</p>
|
||||
<RouterLink to="/dashboard" class="glass-button inline-block px-6 py-3">
|
||||
Back to Dashboard
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Backup Your Identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Create a secure backup of your identity. Set a passphrase and download your encrypted backup file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
|
||||
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<!-- Passphrase Input -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div class="text-left w-full">
|
||||
<label class="block text-xs sm:text-sm font-semibold text-white/80 mb-2 sm:mb-3 uppercase tracking-wide">
|
||||
Backup Passphrase
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
ref="passphraseInput"
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
placeholder="Enter a strong passphrase"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 pl-12 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/60 mt-2 sm:mt-3">
|
||||
Keep this passphrase safe. You'll need it to restore your identity from backup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
@click="downloadBackup"
|
||||
:disabled="!passphrase || isDownloading"
|
||||
class="path-action-button path-action-button--continue w-full"
|
||||
>
|
||||
<span v-if="!isDownloading && !downloaded">Backup to Continue</span>
|
||||
<span v-else-if="isDownloading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center gap-2">
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Downloaded
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="downloaded" class="text-center">
|
||||
<p class="text-sm text-white/70">
|
||||
Backup saved as <span class="font-mono text-white/90">archipelago-did-backup.json</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!downloaded"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const passphraseInput = ref<HTMLInputElement | null>(null)
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const passphrase = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
passphraseInput.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const isDownloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
async function downloadBackup() {
|
||||
if (!passphrase.value) return
|
||||
|
||||
isDownloading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const backupData = await rpcClient.createBackup(passphrase.value)
|
||||
|
||||
const json = JSON.stringify(backupData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
// Use a visible link appended to DOM for better mobile compatibility
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-did-backup.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Delay cleanup so mobile browsers can start the download
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
downloaded.value = true
|
||||
localStorage.setItem('neode_backup_created', '1')
|
||||
// Focus Continue button after backup completes
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 100)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Failed to create backup. Please try again.'
|
||||
}
|
||||
} finally {
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/verify').catch(() => {})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header (before DID is retrieved) -->
|
||||
<div v-if="!generatedDid" class="text-center flex-shrink-0">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-3 sm:mb-6 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your node's identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-4 sm:mb-6">
|
||||
Your node has a Decentralized Identifier (DID) for secure, passwordless authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Waiting for server / Generating state -->
|
||||
<div v-if="!generatedDid && (isGenerating || waitingForServer)" class="text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1–3 minutes after first boot</p>
|
||||
<p v-if="!waitingForServer" class="text-lg text-white/80">Generating your identity key...</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated DID Display -->
|
||||
<div v-if="generatedDid" class="w-full max-w-[600px] space-y-4">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your node's decentralized identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID Display Card -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
|
||||
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
|
||||
{{ generatedDid }}
|
||||
</p>
|
||||
<button
|
||||
@click="copyDid"
|
||||
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
|
||||
:title="didCopied ? 'Copied!' : 'Copy DID'"
|
||||
>
|
||||
<svg v-if="!didCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mb-3">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Nostr ID -->
|
||||
<div v-if="nostrNpub" class="text-left mt-4">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
|
||||
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
|
||||
{{ nostrNpub }}
|
||||
</p>
|
||||
<button
|
||||
@click="copyNpub"
|
||||
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
|
||||
:title="npubCopied ? 'Copied!' : 'Copy npub'"
|
||||
>
|
||||
<svg v-if="!npubCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const generatedDid = ref<string>('')
|
||||
const nostrNpub = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const didCopied = ref(false)
|
||||
const npubCopied = ref(false)
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
function storeDidState(did: string, pubkey: string) {
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: `${did}#key-1`, pubkey }))
|
||||
}
|
||||
|
||||
async function fetchDid() {
|
||||
if (!waitingForServer.value) {
|
||||
isGenerating.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
stopTimers()
|
||||
generatedDid.value = did
|
||||
storeDidState(did, pubkey)
|
||||
isGenerating.value = false
|
||||
waitingForServer.value = false
|
||||
|
||||
// Fetch Nostr npub in parallel (non-blocking)
|
||||
rpcClient.getNostrPubkey().then(({ nostr_npub }) => {
|
||||
if (nostr_npub) {
|
||||
nostrNpub.value = nostr_npub
|
||||
localStorage.setItem('neode_nostr_npub', nostr_npub)
|
||||
}
|
||||
}).catch(() => { /* Nostr key may not exist yet */ })
|
||||
|
||||
} catch {
|
||||
isGenerating.value = false
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(fetchDid, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(generatedDid, (did) => {
|
||||
if (did) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const cached = localStorage.getItem('neode_did')
|
||||
const cachedNpub = localStorage.getItem('neode_nostr_npub')
|
||||
if (cachedNpub) nostrNpub.value = cachedNpub
|
||||
if (cached && !cached.includes('...')) {
|
||||
generatedDid.value = cached
|
||||
} else {
|
||||
fetchDid()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
stopTimers()
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
|
||||
function copyDid() {
|
||||
if (!generatedDid.value) return
|
||||
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function copyNpub() {
|
||||
if (!nostrNpub.value) return
|
||||
navigator.clipboard.writeText(nostrNpub.value).catch(() => {})
|
||||
npubCopied.value = true
|
||||
setTimeout(() => { npubCopied.value = false }, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Success Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 text-center space-y-4 sm:space-y-6 px-3 sm:px-4 py-4 sm:py-6">
|
||||
<!-- Success Icon -->
|
||||
<div class="flex justify-center mb-4 sm:mb-6">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6 sm:mb-8">
|
||||
Your sovereign identity is ready. You can now log in and start your journey as a noderunner.
|
||||
</p>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4 mb-6 sm:mb-8 max-w-[700px] mx-auto">
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Sovereign Identity</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Encrypted Backup</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Ready to Use</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FIPS activation status (non-blocking — onboarding never waits on it) -->
|
||||
<div v-if="fipsLabel" class="flex items-center justify-center gap-2 mb-4 text-xs sm:text-sm">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="fipsReady ? 'bg-green-400' : 'bg-orange-400 animate-pulse'"
|
||||
></span>
|
||||
<span class="text-white/60">{{ fipsLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Set Password Button -->
|
||||
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
|
||||
<button
|
||||
ref="setPasswordButton"
|
||||
@click="goToLogin"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Set Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const setPasswordButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
// FIPS auto-activates in a detached task after the seed is written, so it can
|
||||
// lag a few seconds behind onboarding on slow hardware. Surface a gentle status
|
||||
// so the encrypted-transport bring-up reads as "in progress", never "stuck".
|
||||
// This is purely informational — it never blocks moving on to set a password.
|
||||
const fipsLabel = ref('')
|
||||
const fipsReady = ref(false)
|
||||
let fipsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let fipsTries = 0
|
||||
|
||||
async function pollFips() {
|
||||
try {
|
||||
const s = await rpcClient.call<{ key_present?: boolean; service_active?: boolean }>({
|
||||
method: 'fips.status',
|
||||
timeout: 8000,
|
||||
})
|
||||
// Only relevant once a seed-derived FIPS key exists; otherwise stay silent.
|
||||
if (s.key_present) {
|
||||
if (s.service_active) {
|
||||
fipsReady.value = true
|
||||
fipsLabel.value = 'Private connection ready'
|
||||
return // settled — stop polling
|
||||
}
|
||||
fipsLabel.value = 'Securing your private connection…'
|
||||
}
|
||||
} catch {
|
||||
// Backend still booting — ignore and keep polling.
|
||||
}
|
||||
fipsTries += 1
|
||||
if (fipsTries < 20) {
|
||||
fipsTimer = setTimeout(pollFips, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
setPasswordButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
pollFips()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fipsTimer) clearTimeout(fipsTimer)
|
||||
})
|
||||
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
// The login that follows the wizard gets the full dashboard entrance
|
||||
// (zoom + oomph) even when it's a regular password login (e.g. the demo) —
|
||||
// Login.vue consumes this flag on success.
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 sm:mb-4 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Name your identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-4 sm:mb-6">
|
||||
Give your first identity a name and choose how you'll use it. You can create more identities later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
|
||||
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
|
||||
<!-- Name Input -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
|
||||
<input
|
||||
ref="nameInput"
|
||||
v-model="identityName"
|
||||
type="text"
|
||||
placeholder="Personal"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Purpose Selection -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Purpose</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="p in purposes"
|
||||
:key="p.value"
|
||||
@click="playNavSound('action'); selectedPurpose = p.value"
|
||||
class="px-4 py-3 rounded-lg border text-left transition-all"
|
||||
:class="selectedPurpose === p.value
|
||||
? 'bg-white/15 border-white/30 text-white'
|
||||
: 'bg-black/20 border-white/10 text-white/60 hover:bg-white/10 hover:text-white/80'"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0" :class="p.color">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium text-sm">{{ p.label }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 ml-7">{{ p.desc }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / Server starting -->
|
||||
<div v-if="serverStarting" class="text-center mb-4 px-3">
|
||||
<p class="text-orange-400/80 text-sm">Server is still starting up. Your identity will be saved once it's ready.</p>
|
||||
</div>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-3">
|
||||
<button
|
||||
@click="createIdentity"
|
||||
:disabled="isCreating"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="isCreating">Creating...</span>
|
||||
<span v-else>Continue</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const identityName = ref('Personal')
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
nameInput.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const selectedPurpose = ref('personal')
|
||||
const isCreating = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
const purposes = [
|
||||
{ value: 'personal', label: 'Personal', desc: 'Everyday use', color: 'bg-blue-500/30 text-blue-400' },
|
||||
{ value: 'business', label: 'Business', desc: 'Professional', color: 'bg-orange-500/30 text-orange-400' },
|
||||
{ value: 'anonymous', label: 'Anonymous', desc: 'Private', color: 'bg-purple-500/30 text-purple-400' },
|
||||
]
|
||||
|
||||
function isServerStartingError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return /502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)
|
||||
}
|
||||
|
||||
async function createIdentity() {
|
||||
isCreating.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'identity.create',
|
||||
params: {
|
||||
name: identityName.value || 'Personal',
|
||||
purpose: selectedPurpose.value
|
||||
}
|
||||
})
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
} catch (err) {
|
||||
if (isServerStartingError(err)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to create identity'
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-4 sm:p-6">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="glass-card p-8 pt-16 sm:p-12 sm:pt-20 text-center relative overflow-visible onb-card">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-8 sm:-top-10 left-0 right-0 flex justify-center z-10 onb-logo">
|
||||
<div class="logo-gradient-border w-16 h-16 sm:w-20 sm:h-20">
|
||||
<AnimatedLogo no-border fit />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-white mb-3 sm:mb-4 onb-title">
|
||||
Welcome to Archipelago
|
||||
</h1>
|
||||
|
||||
<p class="text-base sm:text-xl text-white/80 mb-8 sm:mb-12 max-w-2xl mx-auto onb-tagline">
|
||||
Your personal server for a sovereign digital life
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref="ctaButton"
|
||||
@click="goToOptions"
|
||||
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
|
||||
>
|
||||
{{ isDemo ? 'Enter the demo →' : 'Unlock your sovereignty →' }}
|
||||
</button>
|
||||
|
||||
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||
<template v-if="!isDemo">
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||
const isDemo = IS_DEMO
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||
setTimeout(() => {
|
||||
ctaButton.value?.focus({ preventScroll: true })
|
||||
}, 2100)
|
||||
})
|
||||
|
||||
/** Any exit from the intro INTO login is the end of the cinematic — hand the
|
||||
* login the one-shot finale flag so the dashboard plays its full first-entry
|
||||
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
|
||||
* full wizard; these are the shortcut exits that used to skip it (which is
|
||||
* why the demo never got the big entrance). */
|
||||
function armOnboardingFinale() {
|
||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function goToOptions() {
|
||||
playNavSound('action')
|
||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||
// to login, which is prefilled with the demo password.
|
||||
if (isDemo) {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
function goToRestore() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
playNavSound('action')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
armOnboardingFinale()
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-card {
|
||||
opacity: 0;
|
||||
animation: onb-card-in 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s forwards;
|
||||
}
|
||||
.onb-logo {
|
||||
opacity: 0;
|
||||
animation: onb-scale-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s forwards;
|
||||
}
|
||||
.onb-title {
|
||||
opacity: 0;
|
||||
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.7s forwards;
|
||||
}
|
||||
.onb-tagline {
|
||||
opacity: 0;
|
||||
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1.0s forwards;
|
||||
}
|
||||
.onb-cta {
|
||||
opacity: 0;
|
||||
animation: onb-fade-in 0.6s ease 1.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes onb-card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes onb-scale-in {
|
||||
from { opacity: 0; transform: scale(0.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes onb-slide-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes onb-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[1200px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<div class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<div class="logo-gradient-border inline-block mb-4 sm:mb-6">
|
||||
<img
|
||||
src="/assets/icon/favico-black-v2.svg"
|
||||
alt="Archipelago"
|
||||
class="w-16 h-16 sm:w-20 sm:h-20"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-white mb-2 sm:mb-4">Choose Your Setup</h1>
|
||||
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-6 px-3 sm:px-4 max-w-[760px] mx-auto w-full">
|
||||
<!-- Fresh Start -->
|
||||
<button
|
||||
@click="selectOption('fresh')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'fresh' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Fresh Start</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Set up a new server from scratch
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Restore from Seed -->
|
||||
<button
|
||||
@click="selectOption('restore')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'restore' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" 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-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore from Seed</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-4 sm:pt-6">
|
||||
<button
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
selected.value = 'fresh'
|
||||
})
|
||||
|
||||
function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
playNavSound('action')
|
||||
if (selected.value === 'restore') {
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
} else {
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center p-3 sm:p-4 md:p-6 relative">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[1200px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 md:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">Your Node, Your Possibilities</h1>
|
||||
<p class="text-xs md:text-sm text-white/75 leading-relaxed">Archipelago gives you the tools to build your sovereign digital life. All of these capabilities are available from your dashboard.</p>
|
||||
</div>
|
||||
|
||||
<!-- Options Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4 flex-shrink-0 mb-4 md:mb-6 px-3 sm:px-4">
|
||||
<!-- Self Sovereignty -->
|
||||
<div class="path-option-card">
|
||||
<div class="icon-wrapper transition-all duration-300">
|
||||
<svg class="w-10 h-10 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white/96 mb-1.5">Self Sovereignty</h3>
|
||||
<p class="text-sm text-white/75 leading-snug">
|
||||
Data, files, ownership, property of my data estate. Own, manage, edit, and even sell your personal data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Community Commerce -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Community Commerce</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Self contained and owned ecommerce system built on bitcoin and mesh networks. Trade freely without intermediaries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sovereign Projects -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign Projects</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Logistics and project management self owned with privacy control. Collaborate without surveillance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data Transmitter -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Data Transmitter</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Assist the new sovereign net with relay points and networking where you get paid for your value.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hoster -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Hoster</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Host services and content, archives, and more to others for micro bitcoin payments. Earn while you serve.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sovereign AI -->
|
||||
<div class="path-option-card">
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign AI</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Run AI models locally on your hardware. No cloud surveillance, complete privacy, full control over your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6 pt-4 sm:pt-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
continueButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your Recovery Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Write down these 24 words in order. They are the only way to recover your node.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
|
||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error (genuine failure — server-starting hiccups retry silently) -->
|
||||
<div v-if="errorMessage" class="text-center">
|
||||
<p class="text-red-400 text-sm mb-3">{{ errorMessage }}</p>
|
||||
<button
|
||||
@click="generateSeed"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
class="bg-black/60 rounded-lg px-2.5 py-1 sm:py-1.5 border border-white/10"
|
||||
>
|
||||
<span class="text-white/40 text-sm font-mono mr-1">{{ i + 1 }}.</span>
|
||||
<span class="text-white/95 text-[1.05rem] font-mono">{{ word }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
|
||||
/>
|
||||
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!confirmed"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
// Transient errors mean the backend is still booting (slow first boot) — we
|
||||
// retry these silently. Anything else is a genuine failure the user should see.
|
||||
function isServerStartingError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return /502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)
|
||||
}
|
||||
|
||||
async function generateSeed() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// seed.generate is idempotent server-side, so retries are safe. Use a
|
||||
// longer timeout than the default 15s — key derivation + disk writes can be
|
||||
// slow on first boot, and we don't want a spurious abort to look like a
|
||||
// failure.
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate', timeout: 30000 })
|
||||
stopTimers()
|
||||
words.value = res.words
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
} catch (err) {
|
||||
loading.value = false
|
||||
if (isServerStartingError(err)) {
|
||||
// Backend not ready yet — keep waiting, retry silently.
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
} else {
|
||||
// Genuine failure — stop the silent loop and surface it with a manual retry.
|
||||
stopTimers()
|
||||
waitingForServer.value = false
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(confirmed, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Restore previously generated seed if navigating back (don't regenerate)
|
||||
const saved = sessionStorage.getItem('_seed_words')
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length === 24) {
|
||||
words.value = parsed
|
||||
return
|
||||
}
|
||||
} catch { /* regenerate */ }
|
||||
}
|
||||
generateSeed()
|
||||
})
|
||||
onUnmounted(() => { stopTimers() })
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
|
||||
router.push('/onboarding/seed-verify').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Restore from Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Enter your 24-word recovery seed to restore your node identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
|
||||
<!-- Restore Success -->
|
||||
<div v-if="restored" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Identity restored successfully
|
||||
</p>
|
||||
</div>
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredDid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="restoredNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Input Grid -->
|
||||
<div v-if="!restored" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<div v-for="i in 24" :key="i" class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 text-[1rem] font-mono pointer-events-none">{{ i }}.</span>
|
||||
<input
|
||||
:ref="el => { if (el) wordInputs[i - 1] = el as HTMLInputElement }"
|
||||
v-model="seedWords[i - 1]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-[1.2rem] text-white/95 font-mono placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all"
|
||||
:placeholder="`word ${i}`"
|
||||
@keydown.enter="i < 24 ? wordInputs[i]?.focus() : restore()"
|
||||
@input="onWordInput(i - 1)"
|
||||
@paste="i === 1 ? onPaste($event) : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2 text-center">
|
||||
Paste all 24 words into the first field to auto-fill
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!restored"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!restored"
|
||||
@click="restore"
|
||||
:disabled="isRestoring || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isRestoring" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Restoring...
|
||||
</span>
|
||||
<span v-else>Restore Identity</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const wordInputs = ref<HTMLInputElement[]>([])
|
||||
const seedWords = ref<string[]>(Array(24).fill(''))
|
||||
const restored = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
const restoredDid = ref('')
|
||||
const restoredNpub = ref('')
|
||||
|
||||
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function onWordInput(index: number) {
|
||||
seedWords.value[index] = (seedWords.value[index] ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')?.trim()
|
||||
if (!text) return
|
||||
|
||||
const pastedWords = text.split(/\s+/)
|
||||
if (pastedWords.length === 24) {
|
||||
event.preventDefault()
|
||||
for (let i = 0; i < 24; i++) {
|
||||
seedWords.value[i] = (pastedWords[i] ?? '').toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!allFilled.value) return
|
||||
isRestoring.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
|
||||
try {
|
||||
const words = seedWords.value.map(w => w.trim().toLowerCase())
|
||||
const res = await rpcClient.call<{ did: string; nostr_npub: string; restored: boolean }>({
|
||||
method: 'seed.restore',
|
||||
params: { words },
|
||||
})
|
||||
|
||||
if (res.restored) {
|
||||
restored.value = true
|
||||
restoredDid.value = res.did
|
||||
restoredNpub.value = res.nostr_npub || ''
|
||||
if (res.did) localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Restore failed. Check your seed words and try again.'
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full max-h-full relative z-10 path-glass-container onb-scroll-container flex flex-col">
|
||||
<!-- Header (hidden after verification) -->
|
||||
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Confirm you wrote down your seed correctly by entering the requested words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 px-6 sm:px-8 py-4">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px] pt-2">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Seed verified successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5 mb-3">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your DID</h3>
|
||||
<button @click="copyText(did)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ did }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr npub -->
|
||||
<div v-if="nostrNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<button @click="copyText(nostrNpub)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ nostrNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Word Input Fields -->
|
||||
<div v-if="!verified" class="w-full max-w-[600px] space-y-3 sm:space-y-4">
|
||||
<div
|
||||
v-for="(idx, i) in challengeIndices"
|
||||
:key="idx"
|
||||
>
|
||||
<label class="block text-xs font-semibold text-white/80 mb-1.5 uppercase tracking-wide text-left">
|
||||
Word #{{ idx + 1 }}
|
||||
</label>
|
||||
<input
|
||||
:ref="el => { if (el) inputRefs[i] = el as HTMLInputElement }"
|
||||
v-model="answers[i]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:placeholder="`Enter word #${idx + 1}`"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white/95 placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all font-mono text-[1.2rem]"
|
||||
@keydown.enter.prevent="i < challengeIndices.length - 1 ? inputRefs[i + 1]?.focus() : verify()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!verified"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!verified"
|
||||
@click="verify"
|
||||
type="button"
|
||||
:disabled="isVerifying || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isVerifying">Verifying...</span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="verified"
|
||||
@click="downloadIdentity"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</span>
|
||||
<button
|
||||
v-if="verified"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const inputRefs = ref<HTMLInputElement[]>([])
|
||||
const words = ref<string[]>([])
|
||||
const challengeIndices = ref<number[]>([])
|
||||
const answers = ref<string[]>(['', '', '', ''])
|
||||
const verified = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const did = ref('')
|
||||
const nostrNpub = ref('')
|
||||
const copiedField = ref('')
|
||||
|
||||
const allFilled = computed(() => answers.value.every(a => a.trim().length > 0))
|
||||
|
||||
function pickRandomIndices(count: number, max: number): number[] {
|
||||
const indices = new Set<number>()
|
||||
while (indices.size < count) {
|
||||
indices.add(Math.floor(Math.random() * max))
|
||||
}
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = sessionStorage.getItem('_seed_words')
|
||||
if (!stored) {
|
||||
router.replace('/onboarding/seed').catch(() => {})
|
||||
return
|
||||
}
|
||||
words.value = JSON.parse(stored)
|
||||
|
||||
// Restore challenge indices so going back and returning asks the same words
|
||||
const savedIndices = sessionStorage.getItem('_seed_challenge_indices')
|
||||
if (savedIndices) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedIndices)
|
||||
if (Array.isArray(parsed) && parsed.length === 4) {
|
||||
challengeIndices.value = parsed
|
||||
} else {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
} catch {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
} else {
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
}
|
||||
sessionStorage.setItem('_seed_challenge_indices', JSON.stringify(challengeIndices.value))
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
copiedField.value = text === did.value ? 'did' : 'npub'
|
||||
setTimeout(() => { copiedField.value = '' }, 2000)
|
||||
}
|
||||
|
||||
function downloadIdentity() {
|
||||
const data = {
|
||||
did: did.value,
|
||||
nostr_npub: nostrNpub.value || undefined,
|
||||
created: new Date().toISOString(),
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-identity.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url) }, 1000)
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (!allFilled.value) return
|
||||
isVerifying.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const correct = challengeIndices.value.every(
|
||||
(wordIdx, i) => (answers.value[i] ?? '').trim().toLowerCase() === (words.value[wordIdx] ?? '')
|
||||
)
|
||||
|
||||
if (!correct) {
|
||||
isVerifying.value = false
|
||||
errorMessage.value = 'One or more words are incorrect. Please check your written seed and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ verified: boolean; did: string; nostr_npub: string }>({
|
||||
method: 'seed.verify',
|
||||
params: { words: words.value, indices: challengeIndices.value },
|
||||
})
|
||||
|
||||
if (res.verified) {
|
||||
verified.value = true
|
||||
did.value = res.did
|
||||
nostrNpub.value = res.nostr_npub || ''
|
||||
localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
sessionStorage.removeItem('_seed_words')
|
||||
sessionStorage.removeItem('_seed_challenge_indices')
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
} else {
|
||||
errorMessage.value = 'Verification failed. Please try again.'
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
// Words already matched the local copy, so a network/server hiccup here is
|
||||
// transient — the backend just needs a moment. Don't alarm the user; let
|
||||
// them tap Verify again.
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)) {
|
||||
errorMessage.value = 'Server is still starting. Please tap Verify again in a moment.'
|
||||
} else {
|
||||
errorMessage.value = msg || 'Verification failed'
|
||||
}
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container">
|
||||
<!-- Header -->
|
||||
<div v-if="!verified" class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Sign a challenge to verify your decentralized identity is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
<p v-else-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<!-- Sign Button (if not verified yet) -->
|
||||
<button
|
||||
ref="signButton"
|
||||
v-if="!verified"
|
||||
@click="signChallenge"
|
||||
:disabled="isSigning"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="!isSigning">Sign Challenge</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your identity has been successfully verified and is ready to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Signature Display -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Signature</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs break-all leading-relaxed">
|
||||
{{ signature }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="finishButton"
|
||||
v-if="verified"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Finish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const signButton = ref<HTMLButtonElement | null>(null)
|
||||
const finishButton = ref<HTMLButtonElement | null>(null)
|
||||
const verified = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
signButton.value?.focus({ preventScroll: true })
|
||||
}, 500)
|
||||
})
|
||||
const isSigning = ref(false)
|
||||
const signature = ref('')
|
||||
const currentChallenge = ref('')
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
|
||||
/** Generate a cryptographically random challenge (32 bytes, base64) */
|
||||
function generateChallenge(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
async function signChallenge() {
|
||||
isSigning.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
currentChallenge.value = generateChallenge()
|
||||
const { signature: sig } = await rpcClient.signChallenge(currentChallenge.value)
|
||||
signature.value = sig
|
||||
isSigning.value = false
|
||||
|
||||
// Auto-verify the signature using identity.verify
|
||||
const did = localStorage.getItem('neode_did')
|
||||
if (did) {
|
||||
const result = await rpcClient.call({
|
||||
method: 'identity.verify',
|
||||
params: { did, message: currentChallenge.value, signature: sig },
|
||||
}) as { valid: boolean }
|
||||
verified.value = result.valid !== false
|
||||
} else {
|
||||
verified.value = true
|
||||
}
|
||||
nextTick(() => {
|
||||
setTimeout(() => finishButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
const isRetryable = /502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)
|
||||
if (!isRetryable || attempt === 2) {
|
||||
if (isRetryable) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Failed to sign challenge. Please try again.'
|
||||
}
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
isSigning.value = false
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
playNavSound('action')
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch {
|
||||
/* localStorage fallback ensures we can proceed */
|
||||
}
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="onb-viewport relative overflow-hidden">
|
||||
<!-- Background layers with 3D perspective and zoom effect -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Video background for intro/login routes (smooth transition from splash) -->
|
||||
<video
|
||||
v-if="useVideoBackground"
|
||||
ref="videoElement"
|
||||
class="bg-layer"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-intro.jpg"
|
||||
style="width: 100%; height: 100%; object-fit: cover; object-position: center; position: absolute; inset: 0; transform: scale(1); transition: none;"
|
||||
@pause.prevent="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Login: static background + archipelago-style glitch (no zoom) -->
|
||||
<template v-else-if="isLoginRoute">
|
||||
<div
|
||||
class="bg-layer bg-login-static bg-fullwidth"
|
||||
:style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }"
|
||||
/>
|
||||
<!-- Archipelago-style glitch overlays - continuous every 5s -->
|
||||
<div class="login-glitch-layer login-glitch-1" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
|
||||
<div class="login-glitch-layer login-glitch-2" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
|
||||
<div class="login-glitch-scan" />
|
||||
</template>
|
||||
|
||||
<!-- Static image background for other routes (with zoom on transition) -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-layer bg-zoom"
|
||||
:class="{ 'bg-zoom-in': isTransitioning }"
|
||||
:style="{ backgroundImage: `url('/assets/img/${currentBackground}')` }"
|
||||
:key="currentBackground"
|
||||
></div>
|
||||
|
||||
<!-- Glitch overlay layer - only for non-video, non-login background changes -->
|
||||
<div v-show="isGlitching && !useVideoBackground && !isLoginRoute" class="bg-glitch-layer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content with 3D transitions -->
|
||||
<div class="perspective-container-wrapper">
|
||||
<div class="perspective-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="transitionName">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
|
||||
|
||||
const route = useRoute()
|
||||
const currentBackground = ref('bg-intro.jpg')
|
||||
const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const transitionName = ref('depth-forward')
|
||||
|
||||
// Ordered onboarding steps for direction detection
|
||||
const onboardingOrder = [
|
||||
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
|
||||
'/onboarding/seed', '/onboarding/seed-verify', '/onboarding/seed-restore',
|
||||
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
|
||||
'/onboarding/verify', '/onboarding/done', '/login'
|
||||
]
|
||||
|
||||
// Routes that should use video background (smooth transition from splash, loops through login)
|
||||
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||||
|
||||
// Login uses video when coming from splash, or static + glitch when direct
|
||||
const isLoginRoute = computed(() => route.path === '/login')
|
||||
|
||||
|
||||
// Check if current route should use video background.
|
||||
// The FIRST login (nobody has logged in / set a password yet) keeps the VIDEO
|
||||
// running so the splash → login handoff is one continuous shot; only after a
|
||||
// successful login does /login switch to the rotating static backgrounds.
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const hasLoggedInBefore = () => {
|
||||
try {
|
||||
return localStorage.getItem('neode_first_login_done') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const useVideoBackground = computed(() => {
|
||||
if (!videoBackgroundRoutes.includes(route.path)) return false
|
||||
if (route.path === '/login' && hasLoggedInBefore()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Map each route to a specific background image
|
||||
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
|
||||
const routeBackgrounds: Record<string, string> = {
|
||||
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
|
||||
'/onboarding/options': 'bg-intro-4.webp',
|
||||
'/onboarding/path': 'bg-intro-3.jpg',
|
||||
'/onboarding/seed': 'bg-intro-1.webp',
|
||||
'/onboarding/seed-verify': 'bg-intro-1.webp',
|
||||
'/onboarding/seed-restore': 'bg-intro-1.webp',
|
||||
'/onboarding/did': 'bg-intro-4.webp',
|
||||
'/onboarding/identity': 'bg-intro-1.webp',
|
||||
'/onboarding/backup': 'bg-intro-6.webp',
|
||||
'/onboarding/verify': 'bg-intro-2.jpg',
|
||||
'/onboarding/done': 'bg-intro-1.webp',
|
||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||
}
|
||||
|
||||
// Until the user has logged in (or set their password) once, the login
|
||||
// background stays PINNED to the same still the intro video ends on — the
|
||||
// video → login handoff only reads as one continuous shot when both show the
|
||||
// identical frame. From the second login onward the lock screen rotates
|
||||
// through bg-intro-1..6 for variety (counter persisted in localStorage).
|
||||
// Login.vue sets neode_first_login_done on every successful login.
|
||||
const LOGIN_BACKGROUNDS = [
|
||||
'bg-intro-1.webp',
|
||||
'bg-intro-2.jpg',
|
||||
'bg-intro-3.jpg',
|
||||
'bg-intro-4.webp',
|
||||
'bg-intro-5.webp',
|
||||
'bg-intro-6.webp',
|
||||
]
|
||||
function pickNextLoginBackground(): string {
|
||||
try {
|
||||
if (localStorage.getItem('neode_first_login_done') !== '1') return 'bg-intro.jpg'
|
||||
const raw = localStorage.getItem('neode_login_bg_idx')
|
||||
const prev = raw !== null ? parseInt(raw, 10) : -1
|
||||
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
||||
localStorage.setItem('neode_login_bg_idx', String(next))
|
||||
return LOGIN_BACKGROUNDS[next]!
|
||||
} catch {
|
||||
return 'bg-intro.jpg'
|
||||
}
|
||||
}
|
||||
const loginBackground = ref(pickNextLoginBackground())
|
||||
watch(() => route.path, (p) => {
|
||||
if (p === '/login') loginBackground.value = pickNextLoginBackground()
|
||||
})
|
||||
|
||||
// Restore video time from splash screen for seamless transition
|
||||
function restoreVideoTime() {
|
||||
if (videoElement.value && useVideoBackground.value) {
|
||||
const savedTime = sessionStorage.getItem('video_intro_currentTime')
|
||||
const wasPlaying = sessionStorage.getItem('video_intro_wasPlaying') === 'true'
|
||||
const savedPlaybackRate = sessionStorage.getItem('video_intro_playbackRate')
|
||||
|
||||
if (savedTime) {
|
||||
const time = parseFloat(savedTime)
|
||||
const playbackRate = savedPlaybackRate ? parseFloat(savedPlaybackRate) : 1.0
|
||||
|
||||
const setVideoTime = () => {
|
||||
if (videoElement.value) {
|
||||
// Set playback rate first for smooth playback
|
||||
videoElement.value.playbackRate = playbackRate
|
||||
// Set time with slight offset to ensure smooth transition (avoid frame boundary issues)
|
||||
videoElement.value.currentTime = Math.max(0, time - 0.05)
|
||||
|
||||
// If video was playing, ensure it continues playing immediately
|
||||
if (wasPlaying) {
|
||||
requestAnimationFrame(() => ensureVideoPlaying())
|
||||
}
|
||||
|
||||
// Clean up session storage after successful restore
|
||||
sessionStorage.removeItem('video_intro_currentTime')
|
||||
sessionStorage.removeItem('video_intro_wasPlaying')
|
||||
sessionStorage.removeItem('video_intro_playbackRate')
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement.value.readyState >= 2) {
|
||||
// Video can play through current position, set time immediately
|
||||
setVideoTime()
|
||||
} else if (videoElement.value.readyState >= 1) {
|
||||
// Video metadata loaded, set time immediately
|
||||
setVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
setVideoTime()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}
|
||||
videoElement.value.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video play with retry - hardened for reliability
|
||||
function ensureVideoPlaying(retries = 3): void {
|
||||
const vid = videoElement.value
|
||||
if (!vid || !useVideoBackground.value) return
|
||||
if (!vid.paused) return
|
||||
vid.play()
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
if (retries > 0) setTimeout(() => ensureVideoPlaying(retries - 1), 300)
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure video plays when route uses video background
|
||||
watch([useVideoBackground, route], ([useVideo]) => {
|
||||
if (useVideo && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition first
|
||||
restoreVideoTime()
|
||||
// Then ensure it's playing - use double RAF for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value && videoElement.value.paused) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Also ensure video plays on mount if route uses video
|
||||
onMounted(() => {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition
|
||||
restoreVideoTime()
|
||||
// Use double RAF for smoother playback start
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for video element to restore time when it becomes available
|
||||
watch(videoElement, (element) => {
|
||||
if (element && useVideoBackground.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
// Try to restore immediately if metadata already loaded
|
||||
if (element.readyState >= 2) {
|
||||
restoreVideoTime()
|
||||
} else if (element.readyState >= 1) {
|
||||
restoreVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
restoreVideoTime()
|
||||
element.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
element.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch route changes for background swaps, zoom, glitch, and transition direction
|
||||
watch(() => route.path, (newPath, oldPath) => {
|
||||
// Determine slide direction based on route order
|
||||
const oldIdx = onboardingOrder.indexOf(oldPath || '')
|
||||
const newIdx = onboardingOrder.indexOf(newPath)
|
||||
if (oldIdx >= 0 && newIdx >= 0) {
|
||||
transitionName.value = newIdx >= oldIdx ? 'slide-left' : 'slide-right'
|
||||
} else {
|
||||
transitionName.value = 'depth-forward'
|
||||
}
|
||||
const newBg = routeBackgrounds[newPath]
|
||||
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
|
||||
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
|
||||
|
||||
// If both old and new routes use video, don't restart video - keep it playing seamlessly
|
||||
if (oldUsesVideo && newUsesVideo && videoElement.value) {
|
||||
// Video continues seamlessly, just ensure it's playing
|
||||
if (videoElement.value.paused) {
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
// No glitch effect, no zoom, no transitions for video-to-video
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
return // Skip background change logic for video-to-video transitions
|
||||
}
|
||||
|
||||
// If transitioning from video to non-video or vice versa, no glitch, no zoom (smooth transition)
|
||||
if (oldUsesVideo || newUsesVideo) {
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
|
||||
if (newPath === '/login') {
|
||||
currentBackground.value = 'bg-intro-1.webp'
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Only update if we have a defined background for this route and it's different
|
||||
if (newBg && newBg !== currentBackground.value) {
|
||||
// Trigger zoom animation ONLY for non-video routes (never for video)
|
||||
if (!newUsesVideo && !oldUsesVideo) {
|
||||
isTransitioning.value = true
|
||||
|
||||
// Change background
|
||||
currentBackground.value = newBg
|
||||
|
||||
// Only trigger glitch for non-video background changes
|
||||
setTimeout(() => {
|
||||
isGlitching.value = true
|
||||
setTimeout(() => {
|
||||
isGlitching.value = false
|
||||
}, 500) // Match glitch duration
|
||||
|
||||
// Reset zoom after glitch
|
||||
isTransitioning.value = false
|
||||
}, 1500 + 50) // Wait for 3D transition (1500ms) + small delay - matches splash timing
|
||||
} else {
|
||||
// Smooth transition for video routes - no glitch, no zoom, no effects at all
|
||||
currentBackground.value = newBg
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent video from pausing during transitions
|
||||
function handleVideoPause(event: Event) {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
event.preventDefault()
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video ended - restart immediately for seamless loop
|
||||
function handleVideoEnded() {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
videoElement.value.currentTime = 0
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Update body class to disable global glitch effects ONLY for video backgrounds
|
||||
// This class is ONLY added on /onboarding/intro (login uses its own glitch)
|
||||
// All other routes will have glitch effects enabled (normal behavior)
|
||||
watch(useVideoBackground, (usesVideo) => {
|
||||
if (usesVideo) {
|
||||
// Add class ONLY on video background screens (/onboarding/intro, /login)
|
||||
// This disables glitch effects ONLY on these screens
|
||||
document.body.classList.add('video-background-active')
|
||||
} else {
|
||||
// Remove class on all other screens to re-enable glitch effects
|
||||
document.body.classList.remove('video-background-active')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize background on mount based on current route
|
||||
onMounted(() => {
|
||||
const bg = routeBackgrounds[route.path]
|
||||
if (bg) {
|
||||
currentBackground.value = bg
|
||||
}
|
||||
|
||||
if (useVideoBackground.value) {
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
document.body.classList.add('video-background-active')
|
||||
const unlock = () => {
|
||||
resumeAudioContext()
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
}
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
document.removeEventListener('keydown', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
document.addEventListener('keydown', unlock, { once: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fixed viewport container — locks to screen on mobile, no bounce/overflow */
|
||||
.onb-viewport {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Perspective container for 3D depth effect */
|
||||
.perspective-container {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View wrapper - allows smooth transitions with absolute positioning */
|
||||
.view-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 2advanced-style: fluid depth transitions */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-1200px) scale(0.6);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.depth-forward-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(500px) scale(1.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Horizontal slide transitions (direction-aware onboarding steps) */
|
||||
.slide-left-enter-active.view-wrapper,
|
||||
.slide-left-leave-active.view-wrapper,
|
||||
.slide-right-enter-active.view-wrapper,
|
||||
.slide-right-leave-active.view-wrapper {
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
}
|
||||
.slide-left-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
.slide-right-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
.slide-right-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
}
|
||||
|
||||
/* Background zoom - 2advanced fluid */
|
||||
.bg-zoom {
|
||||
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.bg-zoom-in {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Subtle 3D tilt - 2advanced layered depth */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-1200px) scale(0.6) rotateX(6deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(500px) scale(1.25) rotateX(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Background 3D container */
|
||||
.bg-perspective-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
perspective: 1000px;
|
||||
perspective-origin: 50% 50%;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
min-width: 100vw;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* Full width background on every screen */
|
||||
.bg-fullwidth {
|
||||
min-width: 100vw;
|
||||
width: 100vw;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Video background styling - video element itself has bg-layer class */
|
||||
.bg-layer video,
|
||||
video.bg-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.bg-static {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
}
|
||||
|
||||
/* Login: static background - just there, no zoom */
|
||||
.bg-login-static {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Archipelago-style glitch overlays for login - continuous every 5s */
|
||||
.login-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-glitch-1 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(22deg) saturate(1.35);
|
||||
animation: login-glitch-shift 5s steps(10, end) infinite;
|
||||
}
|
||||
|
||||
.login-glitch-2 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(-30deg) saturate(1.45);
|
||||
animation: login-glitch-shift-2 5s steps(9, end) infinite;
|
||||
}
|
||||
|
||||
.login-glitch-scan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0) 60%),
|
||||
repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 2px, rgba(0,0,0,0) 2px 4px),
|
||||
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.35) 100%);
|
||||
opacity: 0;
|
||||
animation: login-glitch-scan 5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes login-glitch-shift {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.22; }
|
||||
84% { transform: translate(6px,-2px); clip-path: inset(8% 0 70% 0); }
|
||||
86% { transform: translate(-5px,2px); clip-path: inset(42% 0 40% 0); }
|
||||
88% { transform: translate(3px,0); clip-path: inset(68% 0 10% 0); }
|
||||
91% { transform: translate(-4px,3px); clip-path: inset(18% 0 60% 0); }
|
||||
93% { transform: translate(5px,-3px); clip-path: inset(55% 0 20% 0); }
|
||||
95% { transform: translate(-3px,1px); clip-path: inset(10% 0 80% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes login-glitch-shift-2 {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.18; }
|
||||
84% { transform: translate(-6px,2px); clip-path: inset(12% 0 65% 0); }
|
||||
86% { transform: translate(5px,-1px) skewX(0.6deg); clip-path: inset(36% 0 42% 0); }
|
||||
89% { transform: translate(-3px,2px); clip-path: inset(72% 0 8% 0); }
|
||||
92% { transform: translate(4px,-3px); clip-path: inset(22% 0 58% 0); }
|
||||
95% { transform: translate(-4px,1px); clip-path: inset(50% 0 26% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes login-glitch-scan {
|
||||
0%, 82% { opacity: 0; transform: translateY(-20%); }
|
||||
84% { opacity: 0.4; }
|
||||
90% { opacity: 0.28; }
|
||||
100% { opacity: 0; transform: translateY(115%); }
|
||||
}
|
||||
|
||||
/* Glitch overlay layer */
|
||||
.bg-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0;
|
||||
animation: bg-glitch-flash 500ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-flash {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
10% {
|
||||
opacity: 0.3;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.4;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
40% {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Kiosk (software compositor): flatten THIS screen's background stack.
|
||||
Same failure mode the dashboard kiosk fix addressed — perspective +
|
||||
preserve-3d layers and mix-blend-mode overlays fail to repaint under
|
||||
Chromium software compositing, so the black body fill shows through.
|
||||
The dashboard-only overrides never reached this scoped stack, which is
|
||||
why the kiosk login/onboarding background still went black. Keep 2D
|
||||
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
|
||||
glitch overlays. */
|
||||
/* The full selector must live inside :global() — with `:global(html.kiosk-mode)
|
||||
.bg-layer` the SFC compiler drops the descendant part, emitting bare
|
||||
`html.kiosk-mode { display: none !important }` rules that blank the whole
|
||||
document on kiosk (the v1.7.104 white-screen). */
|
||||
:global(html.kiosk-mode .bg-perspective-container),
|
||||
:global(html.kiosk-mode .perspective-container) {
|
||||
perspective: none !important;
|
||||
}
|
||||
/* Scoped to the onboarding viewport: a bare `.view-wrapper` match also killed
|
||||
the dashboard's route transitions on kiosk (transform:none !important beats
|
||||
the transition classes), freezing every tab change. The dashboard has its
|
||||
own kiosk-safe 2D transition overrides in dashboard-styles.css. */
|
||||
:global(html.kiosk-mode .bg-layer),
|
||||
:global(html.kiosk-mode .onb-viewport .view-wrapper) {
|
||||
transform: none !important;
|
||||
transform-style: flat !important;
|
||||
backface-visibility: visible !important;
|
||||
will-change: auto !important;
|
||||
filter: none !important;
|
||||
}
|
||||
:global(html.kiosk-mode .login-glitch-layer),
|
||||
:global(html.kiosk-mode .login-glitch-scan) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<BootScreen :visible="showBootScreen" @ready="onServerReady" />
|
||||
<div v-if="!showBootScreen" class="min-h-screen flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
|
||||
<svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
/**
|
||||
* Public demo: every fresh boot of the app (first visit OR browser refresh at
|
||||
* the root) replays the intro for the full effect. In-session SPA navigation
|
||||
* never re-triggers it — this only runs when the app boots at '/'.
|
||||
*/
|
||||
function demoRoute() {
|
||||
log('demoRoute', { dest: '/onboarding/intro' })
|
||||
router.replace('/onboarding/intro').catch(() => {})
|
||||
}
|
||||
|
||||
function log(msg: string, data?: unknown) {
|
||||
const ts = new Date().toISOString()
|
||||
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
|
||||
console.log(entry)
|
||||
const prev = sessionStorage.getItem('archipelago_boot_log') || ''
|
||||
sessionStorage.setItem('archipelago_boot_log', prev + entry + '\n')
|
||||
}
|
||||
|
||||
async function quickHealthCheck(timeoutMs = 2000): Promise<boolean> {
|
||||
try {
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), timeoutMs)
|
||||
const res = await fetch('/rpc/v1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server.echo', params: { message: 'ping' } }),
|
||||
signal: ac.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
const ok = res.status !== 502 && res.status !== 503
|
||||
log('healthCheck', { status: res.status, ok })
|
||||
return ok
|
||||
} catch (e) {
|
||||
log('healthCheck FAILED', { error: String(e) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOnboarded(): Promise<boolean> {
|
||||
// No hard timeout here. isOnboardingComplete() already retries with
|
||||
// backoff (see useOnboarding.ts). A 3s Promise.race that resolves to
|
||||
// `false` on timeout was previously the main cause of the intro
|
||||
// flashing on already-onboarded nodes after browser-clear / reboot /
|
||||
// update: if the backend was slow to warm up, we'd force a 'false'
|
||||
// and route the user back through the setup wizard.
|
||||
try {
|
||||
const result = await isOnboardingComplete()
|
||||
log('checkOnboarded', { result })
|
||||
return result
|
||||
} catch (e) {
|
||||
const fallback = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
log('checkOnboarded ERROR, localStorage fallback', { error: String(e), fallback })
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedToApp() {
|
||||
if (IS_DEMO) {
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
log('proceedToApp devMode', { devMode })
|
||||
router.replace('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const onboarded = await checkOnboarded()
|
||||
const dest = onboarded ? '/login' : '/onboarding/intro'
|
||||
log('proceedToApp navigating', { onboarded, dest })
|
||||
router.replace(dest).catch(() => {})
|
||||
}
|
||||
|
||||
function onServerReady() {
|
||||
if (import.meta.env.DEV) console.log('[RootRedirect] onServerReady — setting flag and reloading')
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Do NOT clear neode_onboarding_complete here — that flag must persist
|
||||
// across boot screen reloads so completed onboarding isn't lost.
|
||||
sessionStorage.setItem('archipelago_from_boot', '1')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
log('mounted', { devMode, from_boot: sessionStorage.getItem('archipelago_from_boot'), from_splash: sessionStorage.getItem('archipelago_from_splash') })
|
||||
|
||||
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
|
||||
log('from_boot=1, deferring to SplashScreen')
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
|
||||
log('from_splash=1, proceedToApp')
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
log('devMode shortcut', { devMode })
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
|
||||
if (devMode === 'boot') {
|
||||
log('devMode=boot, showing boot screen')
|
||||
showBootScreen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// First-boot backends can be up but slow (image loads, first-run podman
|
||||
// work), so a single 2s echo timing out used to flash the BootScreen and
|
||||
// then hard-reload seconds later ("loads, refreshes, loads again" on
|
||||
// kiosk). Give it a second, more patient attempt before concluding the
|
||||
// server is down.
|
||||
let isUp = await quickHealthCheck()
|
||||
if (!isUp) {
|
||||
log('healthCheck retry with longer timeout')
|
||||
isUp = await quickHealthCheck(6000)
|
||||
}
|
||||
log('production flow', { isUp })
|
||||
|
||||
if (isUp) {
|
||||
// Demo: per-day intro gate instead of server-side onboarding state.
|
||||
if (IS_DEMO) {
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const onboarded = await checkOnboarded()
|
||||
if (onboarded) {
|
||||
log('server up + onboarded → proceedToApp')
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
log('server up + NOT onboarded → onboarding intro')
|
||||
router.replace('/onboarding/intro').catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// Server not ready. The full BootScreen is meant for a genuine
|
||||
// cold-start (fresh install), not for the brief blip during an
|
||||
// OTA update where the backend restarts. If onboarding has already
|
||||
// completed we just keep the spinner and retry until the server
|
||||
// responds again.
|
||||
const wasOnboardedBefore = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
if (wasOnboardedBefore) {
|
||||
log('server down + onboarded → polling without boot screen')
|
||||
let retries = 0
|
||||
const maxRetries = 30 // 30 * 2s = 60s before giving up and showing boot screen
|
||||
const poll = setInterval(async () => {
|
||||
retries++
|
||||
if (await quickHealthCheck()) {
|
||||
clearInterval(poll)
|
||||
proceedToApp()
|
||||
return
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
clearInterval(poll)
|
||||
log('server still down after retries → falling back to boot screen')
|
||||
showBootScreen.value = true
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
showBootScreen.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root-redirect-fade {
|
||||
animation: root-fade-in 0.3s ease 0.5s forwards;
|
||||
}
|
||||
@keyframes root-fade-in {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,850 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
|
||||
<!-- LUKS Encryption Badge -->
|
||||
<div v-if="diskEncrypted" class="mb-4 px-4 py-2.5 rounded-xl border bg-green-500/5 border-green-500/20 flex items-center gap-2.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-xs text-green-300/80 font-medium">LUKS2 Encrypted Storage</span>
|
||||
</div>
|
||||
|
||||
<!-- Disk Space Warning Banner -->
|
||||
<div
|
||||
v-if="diskWarning"
|
||||
class="mb-6 p-4 rounded-xl border flex items-center justify-between"
|
||||
:class="diskWarning.level === 'critical'
|
||||
? 'bg-red-500/10 border-red-500/30'
|
||||
: 'bg-yellow-500/10 border-yellow-500/30'"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" :class="diskWarning.level === 'critical' ? 'text-red-400' : 'text-yellow-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium" :class="diskWarning.level === 'critical' ? 'text-red-300' : 'text-yellow-300'">
|
||||
{{ diskWarning.level === 'critical' ? 'Disk Space Critical' : 'Disk Space Warning' }}
|
||||
</p>
|
||||
<p class="text-xs text-white/60">
|
||||
{{ diskWarning.used_percent.toFixed(1) }}% used — {{ formatBytes(diskWarning.free_bytes) }} remaining
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="glass-button glass-button-sm px-3 py-1.5 text-xs font-medium rounded"
|
||||
:disabled="diskCleaning"
|
||||
@click="runDiskCleanup"
|
||||
>
|
||||
{{ diskCleaning ? 'Cleaning...' : 'Clean Up' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<QuickActionsCard
|
||||
:services-running="servicesRunning"
|
||||
:restarting="restarting"
|
||||
:tor-status-label="torStatusLabel"
|
||||
:tor-status-color="torStatusColor"
|
||||
:checking-tor="checkingTor"
|
||||
:auto-sync-enabled="autoSyncEnabled"
|
||||
:log-count="logCount"
|
||||
@restart-services="restartServices"
|
||||
@check-tor="checkTorStatus"
|
||||
@update:auto-sync-enabled="autoSyncEnabled = $event"
|
||||
@view-logs="viewLogs"
|
||||
/>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Local Network</h2>
|
||||
<p class="text-white/70 text-sm mb-4">OpenWRT-integrated router and network management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<template v-if="networkLoading">
|
||||
<div v-for="i in 4" :key="i" class="flex items-center justify-between p-3 bg-white/5 rounded-lg animate-pulse">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 bg-white/10 rounded"></div>
|
||||
<div class="w-24 h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
<div class="w-16 h-4 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="networkRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing network...
|
||||
</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-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
<span class="text-white/80 text-sm">Firewall Active</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Protected</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-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" /></svg>
|
||||
<span class="text-white/80 text-sm">WiFi</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.wifiSsid ? 'text-green-400' : 'text-white/40'">{{ networkData.wifiSsid || 'Not connected' }}</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-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<span class="text-white/80 text-sm">Tor</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="torStatusLabel === 'running' ? 'text-green-400' : 'text-white/60'">{{ torStatusLabel === 'running' ? 'Connected' : torStatusLabel === 'checking' ? 'Checking...' : 'Stopped' }}</span>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard/server/openwrt"
|
||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
|
||||
<span class="text-white/80 text-sm">OpenWrt Gateway</span>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-white/30" 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>
|
||||
</router-link>
|
||||
<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-white/60" 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-white/80 text-sm">Port Forwarding</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</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-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-white/80 text-sm">VPN</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||
{{ networkData.vpnConnected ? 'WireGuard' : 'Not Connected' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" /></svg>
|
||||
<span class="text-white/80 text-sm">DNS</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.dnsProvider !== 'system' ? 'text-green-400' : 'text-white/60'">
|
||||
{{ dnsDisplayLabel }}
|
||||
</span>
|
||||
</button>
|
||||
<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-white/60" 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-white/80 text-sm">F*ck IPs Mesh</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FipsNetworkCard />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- VPN Card -->
|
||||
<div class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">VPN</h2>
|
||||
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-top glass-button px-4 py-2 text-sm">Add Device</button>
|
||||
</div>
|
||||
|
||||
<!-- WireGuard Status -->
|
||||
<div class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" :class="networkData.wgIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
||||
<span class="text-xs text-white/50">Server Address</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Not configured' }}</span>
|
||||
<span v-if="networkData.wgPubkey" class="block text-xs font-mono text-white/30 mt-1 truncate">{{ networkData.wgPubkey }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Connected Devices -->
|
||||
<div class="border-t border-white/10 pt-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Connected Devices</span>
|
||||
<span class="text-xs text-white/30">{{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div v-if="vpnPeers.length" class="space-y-1">
|
||||
<div v-for="peer in vpnPeers" :key="peer.name" class="flex items-center justify-between text-xs py-1.5 px-2 bg-white/5 rounded">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-300">WG</span>
|
||||
<button @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white/40 font-mono">{{ peer.ip?.replace(/\/\d+$/, '') || '' }}</span>
|
||||
<button @click="removePeer(peer.name)" :disabled="removingPeer === peer.name" class="p-0.5 rounded hover:bg-white/10 text-white/30 hover:text-red-400 transition-colors" :title="'Remove ' + peer.name">
|
||||
<svg v-if="removingPeer === peer.name" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
<svg v-else 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="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||
</div>
|
||||
|
||||
<!-- mt-auto pins the action to the card bottom so buttons align across
|
||||
equal-height grid cards -->
|
||||
<div class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces (second column on desktop) -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
class="responsive-card-actions-top px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="interfacesLoading">
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfacesRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing interfaces...
|
||||
</div>
|
||||
<div
|
||||
v-for="iface in physicalInterfaces"
|
||||
:key="iface.name"
|
||||
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="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">{{ iface.name }}</p>
|
||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right">
|
||||
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
||||
<p v-else class="text-sm text-white/40">No IP</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
v-if="iface.type === 'wifi'"
|
||||
:model-value="iface.state === 'up'"
|
||||
:disabled="togglingWifiRadio"
|
||||
:aria-label="iface.state === 'up' ? 'Turn off wifi adapter' : 'Turn on wifi adapter'"
|
||||
@update:model-value="toggleWifiRadio(iface)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
<p v-if="wifiRadioError" class="text-xs text-red-400">{{ wifiRadioError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="wifiAvailable" class="responsive-card-actions-bottom mt-auto pt-4">
|
||||
<button
|
||||
@click="showWifiModal = true"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- close VPN+Network 2-col grid -->
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="closeDeviceModal">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
||||
<button @click="closeDeviceModal" class="p-1 rounded hover:bg-white/10 text-white/60"><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="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||
</div>
|
||||
<!-- Loading state (for existing peer config) -->
|
||||
<div v-if="loadingPeerConfig" class="text-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-white/40 mx-auto" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
</div>
|
||||
<!-- Existing peer QR view -->
|
||||
<div v-else-if="peerQrData && !showingNewDevice" class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="sanitizedPeerQrSvg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New device: WireGuard config -->
|
||||
<div v-else>
|
||||
<div v-if="peerQrData">
|
||||
<div class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="sanitizedPeerQrSvg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<p class="text-sm text-white/50 mb-3">Generate a WireGuard config for the standard WireGuard app.</p>
|
||||
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
||||
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="mb-6">
|
||||
<!-- Tor Services -->
|
||||
<TorServicesCard
|
||||
:tor-services="torServices"
|
||||
:tor-services-loading="torServicesLoading"
|
||||
:tor-daemon-running="torDaemonRunning"
|
||||
:tor-restarting="torRestarting"
|
||||
:tor-rotating="torRotating"
|
||||
:tor-deleting="torDeleting"
|
||||
@restart-tor="restartTor"
|
||||
@show-add-service="showAddServiceModal = true"
|
||||
@copy-address="copyTorAddress"
|
||||
@rotate-service="rotateService"
|
||||
@delete-service="deleteService"
|
||||
@toggle-app="toggleTorApp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<ServerModals
|
||||
:show-add-service-modal="showAddServiceModal"
|
||||
:show-wifi-modal="showWifiModal"
|
||||
:show-dns-modal="showDnsModal"
|
||||
:available-apps-for-tor="availableAppsForTor"
|
||||
:adding-service="addingService"
|
||||
:add-service-error="addServiceError"
|
||||
:wifi-scanning="wifiScanning"
|
||||
:wifi-networks="wifiNetworks"
|
||||
:wifi-connecting="wifiConnecting"
|
||||
:wifi-submitting="wifiSubmitting"
|
||||
:wifi-selected-ssid="wifiSelectedSsid"
|
||||
:wifi-error="wifiError"
|
||||
:wifi-scan-error="wifiScanError"
|
||||
:dns-selected-provider="dnsSelectedProvider"
|
||||
:dns-servers="networkData.dnsServers"
|
||||
:dns-applying="dnsApplying"
|
||||
:dns-error="dnsError"
|
||||
:dns-provider-options="dnsProviderOptions"
|
||||
@close-add-service="showAddServiceModal = false"
|
||||
@create-service-for-app="createServiceForApp"
|
||||
@create-service="createService"
|
||||
@close-wifi="showWifiModal = false"
|
||||
@select-wifi="selectWifi"
|
||||
@connect-wifi="connectToWifi"
|
||||
@scan-wifi="scanWifi"
|
||||
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
|
||||
@close-dns="showDnsModal = false; dnsError = ''"
|
||||
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
|
||||
@apply-dns="applyDnsConfig"
|
||||
/>
|
||||
|
||||
<!-- Logs info toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="logsToast" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4">
|
||||
<div class="bg-white/10 border border-white/20 backdrop-blur-sm rounded-lg px-4 py-3 text-white/80 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ logsToast }}</span>
|
||||
<button @click="logsToast = ''" class="text-white/50 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import QuickActionsCard from './server/QuickActionsCard.vue'
|
||||
import TorServicesCard from './server/TorServicesCard.vue'
|
||||
import ServerModals from './server/ServerModals.vue'
|
||||
import FipsNetworkCard from './server/FipsNetworkCard.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { TorServiceInfo } from './server/TorServicesCard.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Service status
|
||||
const servicesRunning = ref(true)
|
||||
const restarting = ref(false)
|
||||
|
||||
// Tor status
|
||||
const torStatusLabel = ref<'running' | 'stopped' | 'checking'>('checking')
|
||||
const checkingTor = ref(false)
|
||||
const torStatusColor = computed(() => {
|
||||
if (torStatusLabel.value === 'running') return 'bg-green-400'
|
||||
if (torStatusLabel.value === 'checking') return 'bg-yellow-400'
|
||||
return 'bg-red-400'
|
||||
})
|
||||
|
||||
// Auto-sync, logs
|
||||
const autoSyncEnabled = ref(true)
|
||||
const logCount = ref(0)
|
||||
|
||||
// Network data
|
||||
const networkLoading = ref(true)
|
||||
const networkRefreshing = ref(false)
|
||||
const networkHasLoaded = ref(false)
|
||||
const networkData = ref({
|
||||
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
||||
})
|
||||
|
||||
// FIPS status row for the Local Network card. Full FIPS card lives below.
|
||||
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const fipsRowLabel = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s) return '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
return 'Inactive'
|
||||
}
|
||||
// Service is active — reflect anchor reachability so the row flips in
|
||||
// sync with the full FIPS card below.
|
||||
if (s.anchor_connected === false) return 'No anchor'
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const fipsRowTextClass = computed(() => {
|
||||
const s = fipsSummary.value
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/60'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
async function loadFipsSummary() {
|
||||
try {
|
||||
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
|
||||
} catch { /* backend too old */ }
|
||||
}
|
||||
|
||||
async function loadNetworkData() {
|
||||
const initialLoad = !networkHasLoaded.value
|
||||
networkLoading.value = initialLoad
|
||||
networkRefreshing.value = !initialLoad
|
||||
try {
|
||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
|
||||
rpcClient.vpnStatus(),
|
||||
rpcClient.dnsStatus(),
|
||||
])
|
||||
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
|
||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
||||
} catch { /* keep existing/default values */ } finally {
|
||||
networkHasLoaded.value = true
|
||||
networkLoading.value = false
|
||||
networkRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// VPN peer management
|
||||
const showAddDeviceModal = ref(false)
|
||||
const newPeerName = ref('')
|
||||
const creatingPeer = ref(false)
|
||||
const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null)
|
||||
// Sanitize like TwoFactorSection's TOTP QR — the SVG is backend-generated,
|
||||
// but v-html without a sanitizer is one compromised RPC away from XSS.
|
||||
const sanitizedPeerQrSvg = computed(() =>
|
||||
DOMPurify.sanitize(peerQrData.value?.qr_svg ?? '', { USE_PROFILES: { svg: true } }),
|
||||
)
|
||||
const peerError = ref('')
|
||||
const copiedConfig = ref(false)
|
||||
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([])
|
||||
|
||||
async function loadVpnPeers() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
|
||||
vpnPeers.value = res.peers || []
|
||||
} catch { /* no peers */ }
|
||||
}
|
||||
|
||||
async function createPeer() {
|
||||
if (!newPeerName.value.trim()) return
|
||||
creatingPeer.value = true
|
||||
peerError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({
|
||||
method: 'vpn.create-peer',
|
||||
params: { name: newPeerName.value.trim() },
|
||||
})
|
||||
peerQrData.value = res
|
||||
loadVpnPeers()
|
||||
} catch (e) {
|
||||
peerError.value = e instanceof Error ? e.message : 'Failed to create peer'
|
||||
} finally {
|
||||
creatingPeer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadingPeerConfig = ref(false)
|
||||
async function showPeerConfig(name: string) {
|
||||
showAddDeviceModal.value = true
|
||||
loadingPeerConfig.value = true
|
||||
peerError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({
|
||||
method: 'vpn.peer-config',
|
||||
params: { name },
|
||||
})
|
||||
peerQrData.value = res
|
||||
} catch (e) {
|
||||
peerError.value = e instanceof Error ? e.message : 'Failed to load config'
|
||||
} finally {
|
||||
loadingPeerConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removingPeer = ref('')
|
||||
async function removePeer(name: string) {
|
||||
removingPeer.value = name
|
||||
try {
|
||||
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
|
||||
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name)
|
||||
} catch { /* ignore */ }
|
||||
finally { removingPeer.value = '' }
|
||||
}
|
||||
|
||||
const showingNewDevice = ref(false)
|
||||
|
||||
function closeDeviceModal() {
|
||||
showAddDeviceModal.value = false
|
||||
peerQrData.value = null
|
||||
newPeerName.value = ''
|
||||
peerError.value = ''
|
||||
showingNewDevice.value = false
|
||||
}
|
||||
|
||||
async function copyPeerConfig() {
|
||||
if (!peerQrData.value?.config) return
|
||||
try { await navigator.clipboard.writeText(peerQrData.value.config) } catch { /* fallback */ }
|
||||
copiedConfig.value = true
|
||||
setTimeout(() => { copiedConfig.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Network interfaces
|
||||
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
|
||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||
|
||||
const interfacesLoading = ref(true)
|
||||
const interfacesRefreshing = ref(false)
|
||||
const interfacesHaveLoaded = ref(false)
|
||||
const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||
|
||||
const togglingWifiRadio = ref(false)
|
||||
const wifiRadioError = ref('')
|
||||
|
||||
const showWifiModal = ref(false)
|
||||
const wifiScanning = ref(false)
|
||||
const wifiNetworks = ref<WifiNetwork[]>([])
|
||||
const wifiConnecting = ref(false)
|
||||
const wifiSubmitting = ref(false)
|
||||
const wifiSelectedSsid = ref('')
|
||||
const wifiPassword = ref('')
|
||||
const wifiError = ref('')
|
||||
const wifiScanError = ref('')
|
||||
|
||||
// DNS
|
||||
const showDnsModal = ref(false)
|
||||
const dnsSelectedProvider = ref('system')
|
||||
const dnsApplying = ref(false)
|
||||
const dnsError = ref('')
|
||||
|
||||
const dnsProviderOptions = [
|
||||
{ value: 'system', label: 'System Default', description: 'DHCP-assigned DNS servers', doh: false },
|
||||
{ value: 'cloudflare', label: 'Cloudflare', description: '1.1.1.1 / 1.0.0.1', doh: true },
|
||||
{ value: 'google', label: 'Google', description: '8.8.8.8 / 8.8.4.4', doh: true },
|
||||
{ value: 'quad9', label: 'Quad9', description: '9.9.9.9 / 149.112.112.112', doh: true },
|
||||
{ value: 'mullvad', label: 'Mullvad', description: '194.242.2.2 (no logging)', doh: true },
|
||||
{ value: 'custom', label: 'Custom', description: 'Enter your own DNS servers', doh: false },
|
||||
]
|
||||
|
||||
type DnsProviderValue = 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
|
||||
|
||||
const dnsDisplayLabel = computed(() => {
|
||||
const p = networkData.value.dnsProvider
|
||||
const opt = dnsProviderOptions.find(o => o.value === p)
|
||||
if (opt && p !== 'system') return `${opt.label}${networkData.value.dnsDoH ? ' (DoH)' : ''}`
|
||||
if (networkData.value.dnsServers.length > 0) return networkData.value.dnsServers.slice(0, 2).join(', ')
|
||||
return 'System Default'
|
||||
})
|
||||
|
||||
async function applyDnsConfig(customServers: string) {
|
||||
dnsApplying.value = true; dnsError.value = ''
|
||||
try {
|
||||
const provider = dnsSelectedProvider.value as DnsProviderValue
|
||||
const params: { provider: DnsProviderValue; servers?: string[] } = { provider }
|
||||
if (provider === 'custom') { params.servers = customServers.split(',').map(s => s.trim()).filter(s => s.length > 0) }
|
||||
const res = await rpcClient.configureDns(params)
|
||||
// Never trust the response shape: an undefined `servers` used to reach the
|
||||
// dnsDisplayLabel computed and crash the whole page render on `.length`.
|
||||
networkData.value.dnsProvider = res?.provider ?? provider
|
||||
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
|
||||
networkData.value.dnsDoH = !!res?.doh_enabled
|
||||
showDnsModal.value = false
|
||||
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
|
||||
}
|
||||
|
||||
async function loadInterfaces() {
|
||||
const initialLoad = !interfacesHaveLoaded.value
|
||||
const hadInterfaces = allInterfaces.value.length > 0
|
||||
interfacesLoading.value = initialLoad
|
||||
interfacesRefreshing.value = !initialLoad
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||
}
|
||||
|
||||
async function toggleWifiRadio(iface: NetworkInterface) {
|
||||
togglingWifiRadio.value = true
|
||||
wifiRadioError.value = ''
|
||||
const enabled = iface.state !== 'up'
|
||||
try {
|
||||
await rpcClient.call({ method: 'network.set-wifi-radio', params: { enabled } })
|
||||
await loadInterfaces()
|
||||
} catch (e) {
|
||||
wifiRadioError.value = e instanceof Error ? e.message : 'Failed to change wifi radio state.'
|
||||
} finally {
|
||||
togglingWifiRadio.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
|
||||
const security = (network?.security || '').trim().toLowerCase()
|
||||
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
wifiScanning.value = true; wifiNetworks.value = []; wifiScanError.value = ''; wifiError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
|
||||
wifiNetworks.value = res.networks
|
||||
} catch (e) {
|
||||
wifiNetworks.value = []
|
||||
wifiScanError.value = e instanceof Error ? e.message : 'WiFi scan failed.'
|
||||
} finally { wifiScanning.value = false }
|
||||
}
|
||||
|
||||
function selectWifi(network: WifiNetwork) {
|
||||
wifiSelectedSsid.value = network.ssid; wifiPassword.value = ''; wifiError.value = ''
|
||||
if (wifiRequiresPassword(network)) {
|
||||
wifiConnecting.value = true
|
||||
} else {
|
||||
connectToWifi('')
|
||||
}
|
||||
}
|
||||
|
||||
async function connectToWifi(password: string) {
|
||||
if (!wifiSelectedSsid.value) return
|
||||
wifiError.value = ''; wifiSubmitting.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })
|
||||
showWifiModal.value = false; wifiConnecting.value = false; wifiPassword.value = ''
|
||||
logsToast.value = 'WiFi connected successfully'; setTimeout(() => { logsToast.value = '' }, 4000); loadInterfaces()
|
||||
} catch (e) { wifiError.value = e instanceof Error ? e.message : 'WiFi connection failed.' } finally { wifiSubmitting.value = false }
|
||||
}
|
||||
|
||||
// Disk space
|
||||
const diskWarning = ref<{ level: 'warning' | 'critical'; used_percent: number; free_bytes: number } | null>(null)
|
||||
const diskEncrypted = ref(false)
|
||||
const diskCleaning = ref(false)
|
||||
|
||||
async function loadDiskStatus() {
|
||||
try {
|
||||
const res = await rpcClient.diskStatus()
|
||||
diskEncrypted.value = !!(res as Record<string, unknown>).encrypted
|
||||
if (res.level === 'warning' || res.level === 'critical') {
|
||||
diskWarning.value = { level: res.level, used_percent: res.used_percent, free_bytes: res.free_bytes }
|
||||
} else { diskWarning.value = null }
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function runDiskCleanup() {
|
||||
diskCleaning.value = true
|
||||
try { await rpcClient.diskCleanup(); await loadDiskStatus(); logsToast.value = 'Disk cleanup completed'; setTimeout(() => { logsToast.value = '' }, 4000) }
|
||||
catch (e) { logsToast.value = `Disk cleanup failed: ${e instanceof Error ? e.message : 'Unknown error'}`; setTimeout(() => { logsToast.value = '' }, 6000) }
|
||||
finally { diskCleaning.value = false }
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = 1024 * 1024 * 1024; const mb = 1024 * 1024
|
||||
if (bytes >= gb) return `${(bytes / gb).toFixed(1)} GB`
|
||||
if (bytes >= mb) return `${(bytes / mb).toFixed(0)} MB`
|
||||
return `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
||||
// Tor Services
|
||||
const torServices = ref<TorServiceInfo[]>([])
|
||||
const torServicesLoading = ref(false)
|
||||
const torDaemonRunning = ref(false)
|
||||
const torRestarting = ref(false)
|
||||
const torRotating = ref<string | false>(false)
|
||||
const torDeleting = ref<string | false>(false)
|
||||
|
||||
const showAddServiceModal = ref(false)
|
||||
const addingService = ref(false)
|
||||
const addServiceError = ref('')
|
||||
|
||||
const availableAppsForTor = computed(() => {
|
||||
const existingNames = new Set(torServices.value.map(s => s.name))
|
||||
return Object.entries(appStore.packages)
|
||||
.filter(([id]) => !existingNames.has(id))
|
||||
.map(([id, pkg]) => ({ id, title: (pkg as { manifest?: { title?: string } })?.manifest?.title || id }))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
})
|
||||
|
||||
async function loadTorServices() {
|
||||
const hadServices = torServices.value.length > 0
|
||||
torServicesLoading.value = true
|
||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
||||
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
|
||||
}
|
||||
|
||||
async function copyTorAddress(address: string) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(address)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = address
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
logsToast.value = 'Onion address copied to clipboard'
|
||||
} catch {
|
||||
logsToast.value = 'Failed to copy address'
|
||||
}
|
||||
setTimeout(() => { logsToast.value = '' }, 3000)
|
||||
}
|
||||
|
||||
async function toggleTorApp(appId: string, enabled: boolean) { try { await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled }, timeout: 90000 }); await loadTorServices() } catch { /* handled */ } }
|
||||
async function rotateService(name: string) { torRotating.value = name; try { await rpcClient.call({ method: 'tor.rotate-service', params: { name }, timeout: 90000 }); await loadTorServices() } catch { /* handled */ } finally { torRotating.value = false } }
|
||||
async function restartTor() { torRestarting.value = true; try { await rpcClient.call({ method: 'tor.restart', timeout: 90000 }); await loadTorServices(); logsToast.value = 'Tor restarted successfully'; setTimeout(() => { logsToast.value = '' }, 3000) } catch { logsToast.value = 'Failed to restart Tor'; setTimeout(() => { logsToast.value = '' }, 5000) } finally { torRestarting.value = false } }
|
||||
async function deleteService(name: string) { torDeleting.value = name; try { await rpcClient.call({ method: 'tor.delete-service', params: { name }, timeout: 90000 }); await loadTorServices(); logsToast.value = `Tor service "${name}" deleted`; setTimeout(() => { logsToast.value = '' }, 3000) } catch { /* handled */ } finally { torDeleting.value = false } }
|
||||
|
||||
async function createServiceForApp(appId: string) {
|
||||
addServiceError.value = ''; addingService.value = true
|
||||
try { await rpcClient.call({ method: 'tor.create-service', params: { name: appId, local_port: 0 }, timeout: 90000 }); showAddServiceModal.value = false; await loadTorServices(); logsToast.value = `Tor service for "${appId}" created`; setTimeout(() => { logsToast.value = '' }, 3000) }
|
||||
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
|
||||
}
|
||||
|
||||
async function createService(name: string, port: number | null) {
|
||||
if (!name || !port) return
|
||||
addServiceError.value = ''; addingService.value = true
|
||||
try { await rpcClient.call({ method: 'tor.create-service', params: { name, local_port: port }, timeout: 90000 }); showAddServiceModal.value = false; await loadTorServices(); logsToast.value = `Tor service "${name}" created`; setTimeout(() => { logsToast.value = '' }, 3000) }
|
||||
catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
|
||||
|
||||
// Poll VPN status every 15s so IP updates after pairing
|
||||
const vpnPollInterval = setInterval(async () => {
|
||||
try {
|
||||
const vpnRes = await rpcClient.vpnStatus()
|
||||
networkData.value.vpnConnected = vpnRes.connected
|
||||
networkData.value.vpnProvider = vpnRes.provider ?? ''
|
||||
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '')
|
||||
networkData.value.wgIp = vpnRes.wg_ip ?? ''
|
||||
} catch { /* ignore */ }
|
||||
}, 15000)
|
||||
onUnmounted(() => clearInterval(vpnPollInterval))
|
||||
|
||||
watch(showWifiModal, (open) => { if (open) scanWifi() })
|
||||
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
|
||||
|
||||
async function restartServices() {
|
||||
restarting.value = true; servicesRunning.value = false
|
||||
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
|
||||
catch (e) { logsToast.value = `Restart failed: ${e instanceof Error ? e.message : 'Unknown error'}`; setTimeout(() => { logsToast.value = '' }, 6000) }
|
||||
const pollHealth = async (retries: number) => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
try { await rpcClient.call({ method: 'server.health', params: {} }); servicesRunning.value = true; restarting.value = false; return } catch { /* still restarting */ }
|
||||
}
|
||||
restarting.value = false; servicesRunning.value = false; torStatusLabel.value = 'stopped'
|
||||
}
|
||||
pollHealth(15)
|
||||
}
|
||||
|
||||
async function checkTorStatus() {
|
||||
checkingTor.value = true; torStatusLabel.value = 'checking'
|
||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' }
|
||||
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false }
|
||||
}
|
||||
|
||||
const logsToast = ref('')
|
||||
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
|
||||
|
||||
defineExpose({
|
||||
allInterfaces,
|
||||
interfacesRefreshing,
|
||||
loadInterfaces,
|
||||
loadNetworkData,
|
||||
loadTorServices,
|
||||
networkData,
|
||||
networkRefreshing,
|
||||
torServices,
|
||||
torServicesLoading,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<AccountSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppSession from '../AppSession.vue'
|
||||
|
||||
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(() => Promise.resolve()),
|
||||
mockPush: vi.fn(() => Promise.resolve()),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockSuppress: vi.fn(),
|
||||
mockResume: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: { appId: 'gitea' },
|
||||
query: { returnTo: '/dashboard/apps' },
|
||||
fullPath: '/dashboard/apps/session/gitea',
|
||||
}),
|
||||
useRouter: () => ({ replace: mockReplace, push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ panelAppId: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ data: { 'package-data': {} } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/screensaver', () => ({
|
||||
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useAppIdentity', () => ({
|
||||
useAppIdentity: () => ({
|
||||
onIdentitySelected: vi.fn(),
|
||||
onIframeLoadIdentity: vi.fn(),
|
||||
handleIdentityRequest: vi.fn(),
|
||||
getStoredIdentity: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useNostrBridge', () => ({
|
||||
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
describe('AppSession mobile new-tab apps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
|
||||
const wrapper = mount(AppSession, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
AppSessionHeader: true,
|
||||
NostrIdentityPicker: true,
|
||||
MobileGamepad: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
|
||||
// (no native bridge in the test) and dismiss the empty session — no
|
||||
// "this app opens in a tab" interstitial.
|
||||
expect(mockWindowOpen).toHaveBeenCalled()
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Cloud from '../Cloud.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePeer() {
|
||||
return {
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer Alpha',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-06-10T10:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cloud peer list', () => {
|
||||
it('keeps peer nodes visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
|
||||
|
||||
const wrapper = mount(Cloud, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
const pending = deferred<{ nodes: [] }>()
|
||||
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Credentials from '../Credentials.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCredential(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: ['VerifiableCredential', 'NodeOperator'],
|
||||
issuer: 'did:key:issuer',
|
||||
credentialSubject: { id: 'did:key:subject' },
|
||||
issuanceDate: '2026-06-10T10:00:00Z',
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Credentials', () => {
|
||||
it('keeps credentials visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
|
||||
if (request.method === 'identity.list-credentials') {
|
||||
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const wrapper = mount(Credentials, {
|
||||
global: {
|
||||
mocks: {
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
|
||||
const pending = deferred<{ credentials: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list-credentials') return pending.promise
|
||||
return Promise.resolve({ identities: [] })
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).toContain('Refreshing credentials...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).not.toContain('Refreshing credentials...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OnboardingOptions from '../OnboardingOptions.vue'
|
||||
|
||||
const push = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('OnboardingOptions', () => {
|
||||
it('shows only usable setup paths', () => {
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
expect(wrapper.text()).toContain('Fresh Start')
|
||||
expect(wrapper.text()).toContain('Restore from Seed')
|
||||
expect(wrapper.text()).not.toContain('Connect Existing')
|
||||
expect(wrapper.text()).not.toContain('Coming Soon')
|
||||
})
|
||||
|
||||
it('continues to fresh seed generation by default', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed')
|
||||
})
|
||||
|
||||
it('routes restore choice to seed restore', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
|
||||
expect(restoreButton).toBeDefined()
|
||||
|
||||
await restoreButton!.trigger('click')
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCatalogItem() {
|
||||
return {
|
||||
id: 'file-1',
|
||||
filename: 'notes.txt',
|
||||
mime_type: 'text/plain',
|
||||
size_bytes: 128,
|
||||
description: '',
|
||||
access: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
describe('PeerFiles', () => {
|
||||
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
|
||||
nodes: [{
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer',
|
||||
trust_level: 'trusted',
|
||||
}],
|
||||
} as never)
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
|
||||
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: 'peer.onion' },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
|
||||
const pending = deferred<{ items: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('Refreshing peer files...')
|
||||
expect(wrapper.text()).not.toContain('Connecting via Tor')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer files...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Server from '../Server.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
dnsStatus: vi.fn(),
|
||||
diskStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||
return mount(Server, {
|
||||
global: {
|
||||
stubs: {
|
||||
QuickActionsCard: true,
|
||||
TorServicesCard: options.renderTorServices ? false : true,
|
||||
ServerModals: true,
|
||||
FipsNetworkCard: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Server network refresh states', () => {
|
||||
it('keeps network overview visible while refresh is pending', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [{}, {}] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
|
||||
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
|
||||
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
expect(wrapper.text()).toContain('Refreshing network...')
|
||||
|
||||
pendingDiagnostics.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
})
|
||||
|
||||
it('keeps network interfaces visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({
|
||||
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: false })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
|
||||
const pendingInterfaces = deferred<{ interfaces: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
expect(wrapper.text()).toContain('Refreshing interfaces...')
|
||||
|
||||
pendingInterfaces.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
})
|
||||
|
||||
it('keeps Tor services visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({
|
||||
services: [{
|
||||
name: 'filebrowser',
|
||||
local_port: 8080,
|
||||
onion_address: 'filebrowser123456789.onion',
|
||||
enabled: true,
|
||||
unauthenticated: false,
|
||||
protocol: false,
|
||||
}],
|
||||
tor_running: true,
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer({ renderTorServices: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
|
||||
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') return pendingTor.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
expect(wrapper.text()).toContain('Refreshing Tor services...')
|
||||
|
||||
pendingTor.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
|
||||
|
||||
describe('cloudPath helpers', () => {
|
||||
it('normalizes query paths', () => {
|
||||
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
|
||||
})
|
||||
|
||||
it('walks to the parent folder without leaving root', () => {
|
||||
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
|
||||
expect(parentCloudPath('/Photos')).toBe('/')
|
||||
expect(parentCloudPath('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
login: vi.fn(),
|
||||
call: vi.fn(),
|
||||
isOnboardingComplete: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoginSounds', () => ({
|
||||
ensureContext: vi.fn(),
|
||||
playLoopStart: vi.fn(),
|
||||
startSynthwave: vi.fn(),
|
||||
stopSynthwave: vi.fn(),
|
||||
playPop: vi.fn(),
|
||||
playLoginSuccessWhoosh: vi.fn(),
|
||||
playTypingSound: vi.fn(),
|
||||
playDashboardLoadOomph: vi.fn(),
|
||||
getContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useOnboarding', () => ({
|
||||
isOnboardingComplete: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AnimatedLogo.vue', () => ({
|
||||
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
|
||||
}))
|
||||
|
||||
const pushMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), onError: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
|
||||
createWebHistory: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub fetch for server health check
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: { message: 'ping' } }),
|
||||
}))
|
||||
|
||||
import Login from '../Login.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
login: {
|
||||
title: 'Welcome Back',
|
||||
setupTitle: 'Create Password',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
loginButton: 'Login',
|
||||
setupButton: 'Create Password',
|
||||
serverStarting: 'Starting server...',
|
||||
errorMinLength: 'Password must be at least 8 characters',
|
||||
errorMismatch: 'Passwords do not match',
|
||||
errorIncorrect: 'Incorrect password',
|
||||
errorNetwork: 'Unable to reach server',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('Login View', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
pushMock.mockResolvedValue(undefined)
|
||||
// Mock health check so Login renders the form (not "Starting server...")
|
||||
mockedRpc.call.mockImplementation(async (opts: any) => {
|
||||
if (opts.method === 'server.echo') return { message: 'pong' }
|
||||
if (opts.method === 'auth.isSetup') return { isSetup: true }
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
function mountLogin() {
|
||||
return shallowMount(Login, {
|
||||
global: {
|
||||
plugins: [createPinia(), i18n],
|
||||
stubs: {
|
||||
AnimatedLogo: defineComponent({ render: () => h('div') }),
|
||||
Transition: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('renders login page', () => {
|
||||
const wrapper = mountLogin()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('contains a password input', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
const input = wrapper.find('input[type="password"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows title text', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Welcome Back')
|
||||
})
|
||||
|
||||
it('has a login button', async () => {
|
||||
const wrapper = mountLogin()
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
|
||||
expect(loginBtn).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error for empty password submission', async () => {
|
||||
const wrapper = mountLogin()
|
||||
// Find and submit the form
|
||||
const form = wrapper.find('form')
|
||||
if (form.exists()) {
|
||||
await form.trigger('submit')
|
||||
} else {
|
||||
// Try clicking submit button
|
||||
const btn = wrapper.findAll('button').find(b =>
|
||||
b.text().includes('Login') || b.text().includes('Create')
|
||||
)
|
||||
if (btn) await btn.trigger('click')
|
||||
}
|
||||
// No assertion on specific error text — login requires password
|
||||
})
|
||||
|
||||
it('calls rpcClient.login on form submission with password', async () => {
|
||||
mockedRpc.login.mockResolvedValue(null)
|
||||
const wrapper = mountLogin()
|
||||
|
||||
// Set password
|
||||
const input = wrapper.find('input[type="password"]')
|
||||
if (input.exists()) {
|
||||
await input.setValue('testpassword123')
|
||||
}
|
||||
|
||||
// Submit
|
||||
const form = wrapper.find('form')
|
||||
if (form.exists()) {
|
||||
await form.trigger('submit')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Settings from '../Settings.vue'
|
||||
|
||||
describe('Settings View', () => {
|
||||
it('renders AccountSection and SystemSection', () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = shallowMount(Settings)
|
||||
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
|
||||
<p class="text-white/70 mt-2 leading-relaxed">
|
||||
Some features may be unavailable until Bitcoin finishes syncing.
|
||||
Wallet connections and block data require a fully synced node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-orange-400 transition-all duration-500"
|
||||
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm text-white/55 mt-2">
|
||||
{{ bitcoinSyncPercent.toFixed(1) }}% synced — Block {{ bitcoinBlockHeight.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features (if available) -->
|
||||
<div v-if="features.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppScreenshot } from '@/types/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
features: string[]
|
||||
needsBitcoinSync: boolean
|
||||
bitcoinSynced: boolean
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
}>()
|
||||
|
||||
const screenshots = computed(() => {
|
||||
const manifestScreenshots = props.pkg.manifest?.screenshots
|
||||
const staticScreenshots = props.pkg['static-files']?.screenshots
|
||||
return normalizeScreenshots(Array.isArray(staticScreenshots) ? staticScreenshots : manifestScreenshots)
|
||||
})
|
||||
|
||||
function normalizeScreenshots(items: AppScreenshot[] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-start md:items-center gap-4 md:gap-6">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="app-detail-icon archy-app-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-xs md:text-sm mb-2 line-clamp-2 md:line-clamp-none">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 md:px-2.5 md:py-1 rounded-lg text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ $ver(pkg.manifest.version) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
Updating...
|
||||
</span>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`top-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
|
||||
<template v-if="pkg.state === 'updating'">
|
||||
<span
|
||||
class="col-span-2 mobile-card-action bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
Updating...
|
||||
</span>
|
||||
</template>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`bottom-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="[
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
action.class,
|
||||
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||
]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: PackageDataEntry
|
||||
appId: string
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
|
||||
|
||||
const emit = defineEmits<{
|
||||
launch: []
|
||||
start: []
|
||||
stop: []
|
||||
restart: []
|
||||
uninstall: []
|
||||
update: []
|
||||
channels: []
|
||||
}>()
|
||||
|
||||
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
|
||||
|
||||
const actionItems = computed(() => {
|
||||
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
|
||||
|
||||
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
|
||||
actions.push({
|
||||
key: 'update',
|
||||
emit: 'update',
|
||||
label: props.pendingAction === 'update' ? 'Updating...' : `Update to ${displayVersion(props.pkg['available-update'])}`,
|
||||
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||
full: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.packageKey === 'lnd') {
|
||||
actions.push({
|
||||
key: 'channels',
|
||||
emit: 'channels',
|
||||
label: t('appDetails.channels'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.canLaunch) {
|
||||
actions.push({
|
||||
key: 'launch',
|
||||
emit: 'launch',
|
||||
label: t('common.launch'),
|
||||
class: 'glass-button font-semibold',
|
||||
})
|
||||
}
|
||||
|
||||
if (!props.isWebOnly) {
|
||||
if (props.pkg.state === 'stopped' || props.pkg.state === 'exited') {
|
||||
actions.push({
|
||||
key: 'start',
|
||||
emit: 'start',
|
||||
label: props.pendingAction === 'start' ? 'Starting...' : props.pkg.state === 'exited' ? 'Restart' : t('common.start'),
|
||||
class: props.pkg.state === 'exited' ? 'glass-button glass-button-danger' : 'glass-button glass-button-success',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.pkg.state === 'running') {
|
||||
actions.push({
|
||||
key: 'stop',
|
||||
emit: 'stop',
|
||||
label: props.pendingAction === 'stop' ? 'Stopping...' : t('common.stop'),
|
||||
class: 'glass-button text-yellow-200 border-yellow-500/30 hover:bg-yellow-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'restart',
|
||||
emit: 'restart',
|
||||
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
|
||||
actions.push({
|
||||
key: 'uninstall',
|
||||
emit: 'uninstall',
|
||||
label: props.pendingAction === 'uninstall' ? 'Uninstalling...' : t('common.uninstall'),
|
||||
class: 'glass-button text-red-300 border-red-500/30 hover:bg-red-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
function emitAction(action: ActionEmit) {
|
||||
switch (action) {
|
||||
case 'launch':
|
||||
emit('launch')
|
||||
break
|
||||
case 'start':
|
||||
emit('start')
|
||||
break
|
||||
case 'stop':
|
||||
emit('stop')
|
||||
break
|
||||
case 'restart':
|
||||
emit('restart')
|
||||
break
|
||||
case 'uninstall':
|
||||
emit('uninstall')
|
||||
break
|
||||
case 'update':
|
||||
emit('update')
|
||||
break
|
||||
case 'channels':
|
||||
emit('channels')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
if (!target.src.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-detail-actions-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-detail-action-btn {
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.app-detail-actions-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<div class="text-right">
|
||||
<span class="text-white font-medium">{{ $ver(pkg.manifest.version) }}</span>
|
||||
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
|
||||
{{ $ver(pkg['available-update']) }} available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium">App</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version & Updates Card (multi-version apps: Bitcoin Core / Knots).
|
||||
Lets a runner switch versions, pin, and opt into auto-update. See
|
||||
docs/bitcoin-multi-version-design.md §3 Phase 3. -->
|
||||
<div v-if="versionInfo && versionInfo.supportsVersions && versionInfo.versions.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.versionUpdates') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-white/60 text-sm">{{ t('appDetails.runningVersion') }}</span>
|
||||
<span class="text-white font-medium">{{ versionInfo.installedVersion || $ver(pkg.manifest.version) }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">{{ t('appDetails.selectVersion') }}</label>
|
||||
<select
|
||||
v-model="selectedVersion"
|
||||
:disabled="versionBusy"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-orange-400/60"
|
||||
>
|
||||
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span class="text-white/80 text-sm">{{ t('appDetails.autoUpdateLatest') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoUpdate"
|
||||
:disabled="versionBusy || isPinned"
|
||||
class="h-4 w-4 accent-orange-500"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="isPinned" class="text-white/40 text-xs -mt-2">{{ t('appDetails.autoUpdatePinnedNote') }}</p>
|
||||
|
||||
<!-- Downgrade confirmation -->
|
||||
<div v-if="downgradeWarning" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
|
||||
<p class="text-orange-200 text-xs leading-relaxed">⚠️ {{ downgradeWarning }}</p>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-orange-500/80 hover:bg-orange-500 text-white" :disabled="versionBusy" @click="applyVersionConfig(true)">
|
||||
{{ t('appDetails.confirmDowngrade') }}
|
||||
</button>
|
||||
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-white/10 hover:bg-white/20 text-white" :disabled="versionBusy" @click="cancelDowngrade">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="w-full glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-medium py-2"
|
||||
:disabled="versionBusy || !versionDirty"
|
||||
@click="applyVersionConfig(false)"
|
||||
>
|
||||
{{ versionBusy ? t('appDetails.applyingVersion') : t('appDetails.applyVersion') }}
|
||||
</button>
|
||||
<p v-if="versionError" class="text-red-300 text-xs">{{ versionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fedimint Services Card -->
|
||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 py-2 border-b border-white/10">
|
||||
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-orange-300 hover:text-orange-200 text-sm break-all"
|
||||
>
|
||||
{{ interfaceAddresses['lan-address'] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showTorAddress" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" 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>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div v-if="setupInstructions" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-3">{{ t('appDetails.setupInstructions') }}</h3>
|
||||
<p class="text-sm text-white/70 leading-relaxed whitespace-pre-line">
|
||||
{{ setupInstructions }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="credentialsLoading || credentials?.credentials?.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
||||
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div v-if="credentialsLoading" class="space-y-3" aria-live="polite">
|
||||
<div class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="h-3 w-28 rounded bg-white/10 mb-3"></div>
|
||||
<div class="h-4 w-full rounded bg-white/10"></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">Loading credentials...</p>
|
||||
</div>
|
||||
<div v-else-if="credentials" class="space-y-3">
|
||||
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyCredential(cred.label, cred.value)">
|
||||
{{ copiedCredential === cred.label ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card (hidden for web-only apps) -->
|
||||
<div v-if="!isWebOnly" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-orange-300 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card -->
|
||||
<div v-if="links.length > 0" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.kind"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-orange-300 hover:text-orange-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
v-if="link.kind === 'website'"
|
||||
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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.kind === 'source'"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
const copiedCredential = ref('')
|
||||
|
||||
async function copyCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
copiedCredential.value = label
|
||||
setTimeout(() => {
|
||||
if (copiedCredential.value === label) copiedCredential.value = ''
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
packageKey: string
|
||||
isWebOnly: boolean
|
||||
gatewayState: string
|
||||
interfaceAddresses: { 'tor-address': string; 'lan-address': string | null } | null
|
||||
lanUrl: string
|
||||
torUrl: string
|
||||
showTorAddress: boolean
|
||||
credentials: AppCredentialsResponse | null
|
||||
credentialsLoading: boolean
|
||||
}>()
|
||||
|
||||
type LinkKind = 'website' | 'source' | 'documentation'
|
||||
|
||||
interface SidebarLink {
|
||||
kind: LinkKind
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: unknown): string {
|
||||
const url = typeof value === 'string' ? value.trim() : ''
|
||||
if (!url || url === '#') return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return ''
|
||||
}
|
||||
|
||||
const links = computed<SidebarLink[]>(() => {
|
||||
const manifest = props.pkg.manifest || {}
|
||||
const website = normalizeHttpUrl(manifest.website || manifest['marketing-site'])
|
||||
const source = normalizeHttpUrl(manifest['upstream-repo'] || manifest['wrapper-repo'])
|
||||
const documentation = normalizeHttpUrl(manifest['support-site'])
|
||||
|
||||
return [
|
||||
website ? { kind: 'website' as const, label: t('appDetails.website'), url: website } : null,
|
||||
source ? { kind: 'source' as const, label: t('appDetails.sourceCode'), url: source } : null,
|
||||
documentation ? { kind: 'documentation' as const, label: t('appDetails.documentation'), url: documentation } : null,
|
||||
].filter((link): link is SidebarLink => link !== null)
|
||||
})
|
||||
|
||||
const setupInstructions = computed(() => {
|
||||
const raw = props.pkg['static-files']?.instructions
|
||||
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
||||
return instructions ? instructions : ''
|
||||
})
|
||||
|
||||
// ---- Version & Updates (multi-version support) -----------------------------
|
||||
const versionInfo = ref<PackageVersionsResponse | null>(null)
|
||||
const selectedVersion = ref('')
|
||||
const autoUpdate = ref(false)
|
||||
const versionBusy = ref(false)
|
||||
const versionError = ref('')
|
||||
const downgradeWarning = ref('')
|
||||
|
||||
const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
|
||||
// "Apply" is enabled when the runner changed the version or the toggle.
|
||||
const versionDirty = computed(() => {
|
||||
const info = versionInfo.value
|
||||
if (!info) return false
|
||||
return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate
|
||||
})
|
||||
|
||||
// Option label: the floating "latest" entry reads as a sentence (no "v"
|
||||
// prefix); every concrete version is normalized via $ver + status suffixes.
|
||||
function versionOptionLabel(v: CatalogVersionInfo): string {
|
||||
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
|
||||
let label = displayVersion(v.version)
|
||||
if (v.deprecated) label += ' (deprecated)'
|
||||
if (v.eol) label += ` · EOL ${v.eol}`
|
||||
return label
|
||||
}
|
||||
|
||||
// Resolve the dropdown's current value and GUARANTEE it matches a real option,
|
||||
// otherwise the native <select> renders blank. Prefer the pin, then the catalog
|
||||
// default, then the running version — but fall back to the default/first option
|
||||
// when none of those is actually in the list (e.g. a stale installedVersion the
|
||||
// catalog no longer carries, which is what left the control blank).
|
||||
function pickSelection(info: PackageVersionsResponse): string {
|
||||
const options = info.versions.map((v) => v.version)
|
||||
const preferred = info.pinnedVersion || info.default || info.installedVersion || ''
|
||||
if (preferred && options.includes(preferred)) return preferred
|
||||
return info.default && options.includes(info.default)
|
||||
? info.default
|
||||
: info.versions[0]?.version || ''
|
||||
}
|
||||
|
||||
async function loadVersions(appId: string) {
|
||||
versionInfo.value = null
|
||||
versionError.value = ''
|
||||
downgradeWarning.value = ''
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(appId)
|
||||
if (!info.supportsVersions || !info.versions.length) return
|
||||
versionInfo.value = info
|
||||
selectedVersion.value = pickSelection(info)
|
||||
autoUpdate.value = info.autoUpdate
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyVersionConfig(confirm: boolean) {
|
||||
if (!versionInfo.value) return
|
||||
versionBusy.value = true
|
||||
versionError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.setPackageConfig(versionInfo.value.id, {
|
||||
version: selectedVersion.value,
|
||||
autoUpdate: autoUpdate.value,
|
||||
confirm,
|
||||
})
|
||||
if (res.status === 'confirm_required') {
|
||||
downgradeWarning.value = res.warning || t('appDetails.downgradeGeneric')
|
||||
return
|
||||
}
|
||||
downgradeWarning.value = ''
|
||||
// Refresh so the card reflects the new pin / running version.
|
||||
await loadVersions(versionInfo.value.id)
|
||||
} catch (err: unknown) {
|
||||
versionError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
versionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDowngrade() {
|
||||
downgradeWarning.value = ''
|
||||
// Reset the dropdown to the current selection.
|
||||
const info = versionInfo.value
|
||||
if (info) selectedVersion.value = pickSelection(info)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.packageKey,
|
||||
(key) => {
|
||||
if (key) void loadVersions(key)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||
// The backend captures the aezeed at wallet-init time; until the user
|
||||
// confirms writing it down (`acknowledged`), the card renders as a
|
||||
// prominent first-launch prompt.
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const available = ref(false)
|
||||
const acknowledged = ref(true)
|
||||
const statusLoaded = ref(false)
|
||||
|
||||
async function loadStatus(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
|
||||
method: 'lnd.seed-backup-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
available.value = !!res.available
|
||||
acknowledged.value = !!res.acknowledged
|
||||
statusLoaded.value = true
|
||||
return true
|
||||
} catch {
|
||||
// Leave statusLoaded as-is; a one-off RPC blip must not permanently
|
||||
// hide the card (the global banner may have just promised it's here).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Retry a few times — on fresh installs the backend can still be warming
|
||||
// up when the user lands here straight from the backup banner.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
if (await loadStatus()) break
|
||||
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)))
|
||||
}
|
||||
// Deep link from the "Back up your Lightning seed" banner: open the
|
||||
// reveal flow directly so the click visibly does something.
|
||||
if (route.query['seed-backup'] === '1') {
|
||||
router.replace({ query: { ...route.query, 'seed-backup': undefined } }).catch(() => {})
|
||||
if (statusLoaded.value && available.value) openReveal()
|
||||
}
|
||||
})
|
||||
|
||||
// Reveal modal — re-auth gated (password + 2FA when enabled), same UX as
|
||||
// the recovery-phrase reveal in Settings → Backup.
|
||||
const showRevealModal = ref(false)
|
||||
const revealPassword = ref('')
|
||||
const revealCode = ref('')
|
||||
const revealing = ref(false)
|
||||
const revealError = ref('')
|
||||
const revealedWords = ref<string[]>([])
|
||||
const wordsHidden = ref(true)
|
||||
const wordsCopied = ref(false)
|
||||
const acking = ref(false)
|
||||
|
||||
function openReveal() {
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealError.value = ''
|
||||
revealedWords.value = []
|
||||
wordsHidden.value = true
|
||||
showRevealModal.value = true
|
||||
}
|
||||
|
||||
async function submitReveal() {
|
||||
if (revealing.value || !revealPassword.value) return
|
||||
revealing.value = true
|
||||
revealError.value = ''
|
||||
try {
|
||||
const params: Record<string, string> = { password: revealPassword.value }
|
||||
if (revealCode.value) params.code = revealCode.value
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'lnd.seed-reveal', params })
|
||||
revealedWords.value = res.words || []
|
||||
wordsHidden.value = true
|
||||
} catch (e: unknown) {
|
||||
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the Lightning seed'
|
||||
} finally {
|
||||
revealing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedWords.value.join(' '))
|
||||
wordsCopied.value = true
|
||||
setTimeout(() => { wordsCopied.value = false }, 2000)
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
|
||||
async function confirmBackedUp() {
|
||||
if (acking.value) return
|
||||
acking.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'lnd.seed-backup-ack' })
|
||||
acknowledged.value = true
|
||||
closeReveal()
|
||||
} catch { /* keep the modal open; user can retry */ } finally {
|
||||
acking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="statusLoaded && available" class="glass-card px-6 py-6 mb-6" :class="!acknowledged ? 'border border-orange-400/40' : ''">
|
||||
<div v-if="!acknowledged" class="flex items-center gap-2 mb-3 text-orange-300 text-sm font-medium" role="alert">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
|
||||
</svg>
|
||||
Your Lightning seed hasn't been backed up yet
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning wallet seed</h2>
|
||||
<p class="text-sm text-white/60">
|
||||
View the 24-word recovery seed for this node's Lightning wallet. You'll need to
|
||||
confirm your password (and 2FA code, if enabled). Write it down and store it
|
||||
offline — anyone with these words controls your Lightning funds.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="!acknowledged ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||
@click="openReveal"
|
||||
>{{ acknowledged ? 'Reveal' : 'Back up now' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-lnd-seed-title">
|
||||
<h3 id="reveal-lnd-seed-title" class="text-lg font-semibold text-white mb-1">Reveal Lightning seed</h3>
|
||||
|
||||
<template v-if="revealedWords.length === 0">
|
||||
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word Lightning seed.</p>
|
||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Password</label>
|
||||
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
|
||||
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
|
||||
</div>
|
||||
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
{{ revealing ? 'Verifying…' : 'Reveal' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
{{ acking ? 'Saving…' : "I've backed it up" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppContentSection from '../AppContentSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
about: 'About {name}',
|
||||
features: 'Features',
|
||||
screenshots: 'Screenshots',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app details',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountContent(pkg: PackageDataEntry) {
|
||||
return mount(AppContentSection, {
|
||||
props: {
|
||||
pkg,
|
||||
features: [],
|
||||
needsBitcoinSync: false,
|
||||
bitcoinSynced: true,
|
||||
bitcoinSyncPercent: 100,
|
||||
bitcoinBlockHeight: 100,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppContentSection', () => {
|
||||
it('does not show screenshot placeholders when no screenshots exist', () => {
|
||||
const wrapper = mountContent(packageData())
|
||||
|
||||
expect(wrapper.text()).not.toContain('Screenshots')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders real screenshot metadata when provided', () => {
|
||||
const wrapper = mountContent(packageData({
|
||||
manifest: {
|
||||
...packageData().manifest,
|
||||
screenshots: [
|
||||
'/assets/screenshots/example-dashboard.png',
|
||||
{ src: '/assets/screenshots/example-settings.png', alt: 'Settings screen' },
|
||||
' ',
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
|
||||
expect(wrapper.text()).toContain('Screenshots')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0]?.attributes('src')).toBe('/assets/screenshots/example-dashboard.png')
|
||||
expect(images[0]?.attributes('alt')).toBe('Example screenshot 1')
|
||||
expect(images[1]?.attributes('src')).toBe('/assets/screenshots/example-settings.png')
|
||||
expect(images[1]?.attributes('alt')).toBe('Settings screen')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppHeroSection from '../AppHeroSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
common: {
|
||||
launch: 'Launch',
|
||||
restart: 'Restart',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
uninstall: 'Uninstall',
|
||||
},
|
||||
appDetails: {
|
||||
channels: 'Channels',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountHero(props: Partial<InstanceType<typeof AppHeroSection>['$props']> = {}) {
|
||||
return mount(AppHeroSection, {
|
||||
props: {
|
||||
pkg: packageData(),
|
||||
appId: 'example',
|
||||
packageKey: 'example',
|
||||
canLaunch: true,
|
||||
isWebOnly: false,
|
||||
pendingAction: null,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppHeroSection', () => {
|
||||
it('disables app controls while a container action is running', () => {
|
||||
const wrapper = mountHero({ pendingAction: 'restart' })
|
||||
|
||||
expect(wrapper.text()).toContain('Restarting...')
|
||||
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('labels update progress and disables update controls', () => {
|
||||
const wrapper = mountHero({
|
||||
pendingAction: 'update',
|
||||
pkg: packageData({ 'available-update': '1.1.0' }),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Updating...')
|
||||
const updateButtons = wrapper.findAll('button').filter(button => button.text().includes('Updating...'))
|
||||
expect(updateButtons.length).toBeGreaterThan(0)
|
||||
expect(updateButtons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppSidebar from '../AppSidebar.vue'
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
function mountSidebar(manifestOverrides: Record<string, unknown>, propOverrides: Record<string, unknown> = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
access: 'Access',
|
||||
documentation: 'Documentation',
|
||||
gateway: 'Gateway',
|
||||
information: 'Information',
|
||||
lan: 'LAN',
|
||||
links: 'Links',
|
||||
ram: 'RAM',
|
||||
ramDesc: 'Memory required',
|
||||
requirements: 'Requirements',
|
||||
setupInstructions: 'Setup Instructions',
|
||||
requiresTor: 'Requires Tor',
|
||||
services: 'Services',
|
||||
sourceCode: 'Source Code',
|
||||
storage: 'Storage',
|
||||
storageDesc: 'Storage required',
|
||||
tor: 'Tor',
|
||||
website: 'Website',
|
||||
},
|
||||
common: {
|
||||
category: 'Category',
|
||||
developer: 'Developer',
|
||||
license: 'License',
|
||||
status: 'Status',
|
||||
version: 'Version',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(AppSidebar, {
|
||||
props: {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
...manifestOverrides,
|
||||
},
|
||||
'static-files': { license: '', instructions: 'Follow step 1.\nThen step 2.', icon: '' },
|
||||
},
|
||||
packageKey: 'example',
|
||||
isWebOnly: false,
|
||||
gatewayState: 'stopped',
|
||||
interfaceAddresses: null,
|
||||
lanUrl: '',
|
||||
torUrl: '',
|
||||
showTorAddress: false,
|
||||
credentials: null,
|
||||
credentialsLoading: false,
|
||||
...propOverrides,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
it('renders manifest links with real URLs', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: 'https://example.com',
|
||||
'upstream-repo': 'https://github.com/example/app',
|
||||
'support-site': 'https://docs.example.com',
|
||||
})
|
||||
|
||||
const hrefs = wrapper.findAll('a').map(anchor => anchor.attributes('href'))
|
||||
|
||||
expect(hrefs).toEqual([
|
||||
'https://example.com',
|
||||
'https://github.com/example/app',
|
||||
'https://docs.example.com',
|
||||
])
|
||||
expect(wrapper.text()).toContain('Website')
|
||||
expect(wrapper.text()).toContain('Source Code')
|
||||
expect(wrapper.text()).toContain('Documentation')
|
||||
})
|
||||
|
||||
it('does not render dead placeholder links', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: '#',
|
||||
'upstream-repo': '',
|
||||
'support-site': undefined,
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('a')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('Links')
|
||||
})
|
||||
|
||||
it('shows a credentials loading state before credentials arrive', () => {
|
||||
const wrapper = mountSidebar({}, { credentialsLoading: true })
|
||||
|
||||
expect(wrapper.text()).toContain('Credentials')
|
||||
expect(wrapper.text()).toContain('Loading credentials...')
|
||||
})
|
||||
|
||||
it('renders setup instructions when provided', () => {
|
||||
const wrapper = mountSidebar({})
|
||||
|
||||
expect(wrapper.text()).toContain('Setup Instructions')
|
||||
expect(wrapper.text()).toContain('Follow step 1.')
|
||||
expect(wrapper.text()).toContain('Then step 2.')
|
||||
})
|
||||
|
||||
it('hides setup instructions when empty', () => {
|
||||
const wrapper = mountSidebar({}, {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
},
|
||||
'static-files': { license: '', instructions: ' ', icon: '' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Setup Instructions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* AppDetails data: URL maps, route-to-package mappings, aliases, and status helpers.
|
||||
* Extracted from AppDetails.vue to keep the view under 500 lines.
|
||||
*/
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
/** Web-only app detection (no container -- external websites) */
|
||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
'btcpay-server': 'btcpay-server',
|
||||
fedimint: 'fedimint',
|
||||
'fedimint-gateway': 'fedimint-gateway',
|
||||
lnd: 'lnd',
|
||||
'lnd-ui': 'lnd',
|
||||
bitcoin: 'bitcoin-knots',
|
||||
'bitcoin-knots': 'bitcoin-knots',
|
||||
homeassistant: 'homeassistant',
|
||||
'home-assistant': 'homeassistant',
|
||||
grafana: 'grafana',
|
||||
searxng: 'searxng',
|
||||
ollama: 'ollama',
|
||||
nextcloud: 'nextcloud',
|
||||
vaultwarden: 'vaultwarden',
|
||||
jellyfin: 'jellyfin',
|
||||
photoprism: 'photoprism',
|
||||
immich: 'immich',
|
||||
filebrowser: 'filebrowser',
|
||||
'nginx-proxy-manager': 'nginx-proxy-manager',
|
||||
'gitea': 'gitea',
|
||||
portainer: 'portainer',
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
netbird: 'netbird',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
export const PACKAGE_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
}
|
||||
|
||||
export function resolvePackageKey(routeId: string): string {
|
||||
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
|
||||
}
|
||||
|
||||
/** Apps that depend on Bitcoin being synced */
|
||||
export const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver']
|
||||
|
||||
/** App launch URLs for dev and prod environments */
|
||||
export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'lorabell': { dev: 'http://192.168.1.166', prod: 'http://192.168.1.166' },
|
||||
'atob': { dev: 'http://localhost:8102', prod: 'https://app.atobitcoin.io' },
|
||||
'k484': { dev: 'http://localhost:8103', prod: 'http://localhost:8103' },
|
||||
'bitcoin': { dev: 'http://localhost:8332', prod: 'http://localhost:8332' },
|
||||
'btcpay-server': { dev: 'http://localhost:23000', prod: 'http://localhost:23000' },
|
||||
'homeassistant': { dev: 'http://localhost:8123', prod: 'http://localhost:8123' },
|
||||
'grafana': { dev: 'http://localhost:3000', prod: 'http://localhost:3000' },
|
||||
'endurain': { dev: 'http://localhost:8080', prod: 'http://localhost:8080' },
|
||||
'fedimint': { dev: 'http://localhost:8175', prod: 'http://192.168.1.228:8175' },
|
||||
'fedimint-gateway': { dev: 'http://localhost:8176', prod: 'http://192.168.1.228:8176' },
|
||||
'morphos-server': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'lightning-stack': { dev: 'http://localhost:9735', prod: 'http://localhost:9735' },
|
||||
'mempool': { dev: 'http://localhost:4080', prod: 'http://localhost:4080' },
|
||||
'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' },
|
||||
'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' },
|
||||
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
|
||||
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
|
||||
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
|
||||
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
|
||||
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
|
||||
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
|
||||
'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
|
||||
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
|
||||
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
|
||||
}
|
||||
|
||||
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
|
||||
export function isRealOnionAddress(addr: string | undefined): boolean {
|
||||
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200 border border-green-500/30'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
case PackageState.Exited:
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-500/20 text-red-200 border border-red-500/30'
|
||||
: 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusDotClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-400'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-400'
|
||||
case PackageState.Exited:
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-400 animate-pulse'
|
||||
: 'bg-gray-400'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-400 animate-pulse'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-400 animate-pulse'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-400 animate-pulse'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Updating) return 'updating...'
|
||||
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||
if (state === PackageState.Exited) {
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
|
||||
<Transition name="content-fade">
|
||||
<!-- Suppressed while the ElectrumX sync overlay below is showing — both
|
||||
conditions can be true at once during launch (generic loader fires
|
||||
first, then sync status arrives), and the sync screen is strictly
|
||||
more informative, so it takes precedence instead of the two
|
||||
rendering on top of each other. -->
|
||||
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
|
||||
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
|
||||
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
|
||||
<p class="text-white/50 text-sm mb-5">
|
||||
ElectrumX is building its index from the blockchain. The UI opens
|
||||
automatically once it's ready — you can keep using the rest of Archipelago.
|
||||
</p>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400/80 transition-all duration-700"
|
||||
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
|
||||
<p class="text-white/40 text-xs">
|
||||
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
|
||||
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
|
||||
</p>
|
||||
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@focusin="focusIframe"
|
||||
>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
tabindex="0"
|
||||
@load="handleIframeLoad"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
paint over the sync progress and read as a hard error. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-if="!mustOpenNewTab"
|
||||
@click="$emit('refresh')"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Retry now
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('openNewTabAndBack')"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in new tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
|
||||
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
appId: string
|
||||
appTitle: string
|
||||
appIcon: string
|
||||
loading: boolean
|
||||
iframeBlocked: boolean
|
||||
mustOpenNewTab: boolean
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
// Non-null only for ElectrumX while its index is still building — shows the
|
||||
// sync screen and gates the iframe until status flips to "synced".
|
||||
electrsSync?: ElectrsSyncStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
iframeLoad: []
|
||||
iframeError: []
|
||||
refresh: []
|
||||
openNewTabAndBack: []
|
||||
}>()
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// Faux load progress for the loading screen. Cross-origin iframes give no real
|
||||
// progress events, so ease toward ~92% while loading and snap to 100% on load —
|
||||
// far better UX than a black screen with a bare spinner.
|
||||
const loadProgress = ref(0)
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopProgress() {
|
||||
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||||
}
|
||||
|
||||
function startProgress() {
|
||||
stopProgress()
|
||||
loadProgress.value = 8
|
||||
progressTimer = setInterval(() => {
|
||||
// Decelerate as it approaches the cap so it never visually "finishes" early.
|
||||
const remaining = 92 - loadProgress.value
|
||||
loadProgress.value += Math.max(0.4, remaining * 0.08)
|
||||
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
|
||||
}, 180)
|
||||
}
|
||||
|
||||
watch(() => props.loading, (isLoading) => {
|
||||
if (isLoading) {
|
||||
startProgress()
|
||||
} else {
|
||||
stopProgress()
|
||||
loadProgress.value = 100
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
|
||||
|
||||
onBeforeUnmount(stopProgress)
|
||||
|
||||
function focusIframe() {
|
||||
iframeRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function handleIframeLoad() {
|
||||
emit('iframeLoad')
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}
|
||||
|
||||
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
|
||||
if (!props.appUrl || props.iframeBlocked) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ iframeRef })
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="sticky top-0 z-10 hidden md:flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
|
||||
<!-- Back / Forward navigation -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="app-session-btn" aria-label="Back" title="Go back" @click="$emit('goBack')">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-btn" aria-label="Forward" title="Go forward" @click="$emit('goForward')">
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ appTitle }}</span>
|
||||
|
||||
<button class="app-session-btn" aria-label="Refresh" :disabled="isRefreshing" @click="$emit('refresh')">
|
||||
<svg class="w-5 h-5 transition-transform duration-300" :class="{ 'animate-spin': isRefreshing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Display mode: one-click switch -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
|
||||
aria-label="Right panel"
|
||||
title="Right panel"
|
||||
@click="$emit('setMode', 'panel')"
|
||||
>
|
||||
<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="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
|
||||
aria-label="Over whole app"
|
||||
title="Over whole app"
|
||||
@click="$emit('setMode', 'overlay')"
|
||||
>
|
||||
<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="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="app-session-btn"
|
||||
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
|
||||
aria-label="Open fullscreen"
|
||||
title="Open fullscreen"
|
||||
@click="$emit('setMode', 'fullscreen')"
|
||||
>
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="app-session-btn" aria-label="Close" @click="$emit('close')">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DisplayMode } from './appSessionConfig'
|
||||
|
||||
defineProps<{
|
||||
appTitle: string
|
||||
isRefreshing: boolean
|
||||
displayMode: DisplayMode
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
goBack: []
|
||||
goForward: []
|
||||
refresh: []
|
||||
openNewTab: []
|
||||
close: []
|
||||
setMode: [mode: DisplayMode]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<!-- Mobile gamepad overlay — NES-styled D-pad + action buttons.
|
||||
Sends postMessage({ type: 'arcade-input', key, player, action }) to iframe. -->
|
||||
<div class="mobile-gamepad">
|
||||
<!-- D-Pad (left side) -->
|
||||
<div class="gamepad-dpad">
|
||||
<button
|
||||
class="dpad-btn dpad-up"
|
||||
@touchstart.prevent="down('ArrowUp')"
|
||||
@touchend.prevent="up('ArrowUp')"
|
||||
@touchcancel.prevent="up('ArrowUp')"
|
||||
aria-label="Up"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4l-6 8h12z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="dpad-btn dpad-left"
|
||||
@touchstart.prevent="down('ArrowLeft')"
|
||||
@touchend.prevent="up('ArrowLeft')"
|
||||
@touchcancel.prevent="up('ArrowLeft')"
|
||||
aria-label="Left"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 12l8-6v12z"/></svg>
|
||||
</button>
|
||||
<div class="dpad-center" />
|
||||
<button
|
||||
class="dpad-btn dpad-right"
|
||||
@touchstart.prevent="down('ArrowRight')"
|
||||
@touchend.prevent="up('ArrowRight')"
|
||||
@touchcancel.prevent="up('ArrowRight')"
|
||||
aria-label="Right"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 12l-8-6v12z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="dpad-btn dpad-down"
|
||||
@touchstart.prevent="down('ArrowDown')"
|
||||
@touchend.prevent="up('ArrowDown')"
|
||||
@touchcancel.prevent="up('ArrowDown')"
|
||||
aria-label="Down"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20l6-8H6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: START / SELECT + utility buttons -->
|
||||
<div class="gamepad-meta">
|
||||
<div class="meta-row">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
</div>
|
||||
<div class="meta-utility">
|
||||
<button class="util-btn" aria-label="Refresh" @click="$emit('refresh')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Open in browser" @click="$emit('openBrowser')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<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>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Close" @click="$emit('close')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (right side) — triangle layout -->
|
||||
<div class="gamepad-actions">
|
||||
<button
|
||||
class="action-btn action-c"
|
||||
@touchstart.prevent="down('c')"
|
||||
@touchend.prevent="up('c')"
|
||||
@touchcancel.prevent="up('c')"
|
||||
aria-label="Special"
|
||||
></button>
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="action-btn action-b"
|
||||
@touchstart.prevent="down('b')"
|
||||
@touchend.prevent="up('b')"
|
||||
@touchcancel.prevent="up('b')"
|
||||
aria-label="Kick"
|
||||
></button>
|
||||
<button
|
||||
class="action-btn action-a"
|
||||
@touchstart.prevent="down('a')"
|
||||
@touchend.prevent="up('a')"
|
||||
@touchcancel.prevent="up('a')"
|
||||
aria-label="Punch"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
iframeRef: HTMLIFrameElement | null
|
||||
player?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
openBrowser: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function send(key: string, action: 'down' | 'up') {
|
||||
props.iframeRef?.contentWindow?.postMessage(
|
||||
{ type: 'arcade-input', key, player: props.player ?? 1, action },
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
function down(key: string) { send(key, 'down') }
|
||||
function up(key: string) { send(key, 'up') }
|
||||
function tap(key: string) { send(key, 'down'); setTimeout(() => send(key, 'up'), 80) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-gamepad {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ── D-Pad ── */
|
||||
.gamepad-dpad {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 48px 48px;
|
||||
grid-template-rows: 48px 48px 48px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dpad-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.dpad-btn:active {
|
||||
background: rgba(251, 146, 60, 0.3);
|
||||
color: white;
|
||||
}
|
||||
.dpad-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.dpad-up { grid-column: 2; grid-row: 1; }
|
||||
.dpad-left { grid-column: 1; grid-row: 2; }
|
||||
.dpad-center {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dpad-right { grid-column: 3; grid-row: 2; }
|
||||
.dpad-down { grid-column: 2; grid-row: 3; }
|
||||
|
||||
/* ── Meta buttons (START / SELECT) ── */
|
||||
.gamepad-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-utility {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.util-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.util-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.meta-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta-btn:active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* ── Action buttons — triangle layout ── */
|
||||
.gamepad-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid;
|
||||
transition: background 0.1s, transform 0.1s;
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.action-a {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
border-color: rgba(251, 146, 60, 0.5);
|
||||
color: #fb923c;
|
||||
}
|
||||
.action-a:active {
|
||||
background: rgba(251, 146, 60, 0.45);
|
||||
}
|
||||
|
||||
.action-b {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.action-b:active {
|
||||
background: rgba(96, 165, 250, 0.45);
|
||||
}
|
||||
|
||||
.action-c {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #ffffff;
|
||||
}
|
||||
.action-c:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
|
||||
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves direct app ports against the current browser host', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
|
||||
})
|
||||
|
||||
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
||||
// manifest.yml) — assert against the manifest-generated value, which is
|
||||
// exactly what this test exists to protect.
|
||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088')
|
||||
})
|
||||
|
||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('meshtastic')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
|
||||
})
|
||||
|
||||
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
/** Per-app default display mode. Used when the user hasn't explicitly picked
|
||||
* a mode for that app (an explicit pick is remembered per app and wins).
|
||||
* Apps not listed default to 'panel'. */
|
||||
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
|
||||
'indeedhub': 'fullscreen',
|
||||
}
|
||||
|
||||
/** Initial display mode for an app session: per-app user choice → per-app
|
||||
* default → panel. Strictly per-app — deliberately NO global fallback, so
|
||||
* one app's mode change can never affect how another app opens. */
|
||||
export function initialDisplayMode(id: string): DisplayMode {
|
||||
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
|
||||
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
|
||||
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
|
||||
}
|
||||
|
||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||
export const APP_PORTS: Record<string, number> = {
|
||||
...GENERATED_APP_PORTS,
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
'electrumx': 50002,
|
||||
'electrs': 50002,
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool-web': 4080,
|
||||
'ollama': 11434,
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'nginx-proxy-manager': 8081,
|
||||
'netbird': 8087,
|
||||
'tailscale': 8240,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
/** Apps that need nginx proxy for iframe embedding.
|
||||
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
|
||||
export const PROXY_APPS: Record<string, string> = {
|
||||
'gitea': '/app/gitea/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
}
|
||||
|
||||
/** App launches use direct ports. Do not route through /app/... path proxies. */
|
||||
export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** External HTTPS apps -- always loaded directly */
|
||||
export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
...GENERATED_APP_TITLES,
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
'nostrudel': 'noStrudel',
|
||||
}
|
||||
|
||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||
export const NEW_TAB_APPS = new Set([
|
||||
...GENERATED_NEW_TAB_APPS,
|
||||
'nginx-proxy-manager',
|
||||
'tailscale',
|
||||
])
|
||||
|
||||
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||
// Demo: route to the app's mock UI or real external site (mempool.space,
|
||||
// indee.tx1138.com). Carry through a deep-link path (e.g. /tx/<hash> for
|
||||
// mempool). Non-demoable apps fall through to a generic notice page.
|
||||
if (IS_DEMO) {
|
||||
const base = demoAppUrl(id)
|
||||
if (base) {
|
||||
if (!routeQueryPath) return base
|
||||
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
|
||||
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
|
||||
}
|
||||
return `/app/${id}/`
|
||||
}
|
||||
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
|
||||
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return 'http://' + window.location.hostname + ':8334'
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
// Local apps launch by host port.
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = 'http://' + window.location.hostname + ':' + String(port)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
/** Resolve a human-readable title for an app */
|
||||
export function resolveAppTitle(id: string): string {
|
||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */
|
||||
|
||||
export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"aiui": 5180,
|
||||
"archy-mempool-web": 4080,
|
||||
"archy-nbxplorer": 32838,
|
||||
"botfights": 9100,
|
||||
"btcpay-server": 23000,
|
||||
"did-wallet": 8088,
|
||||
"electrumx": 50002,
|
||||
"fedimint": 8175,
|
||||
"filebrowser": 8083,
|
||||
"gitea": 3001,
|
||||
"grafana": 3000,
|
||||
"homeassistant": 8123,
|
||||
"immich": 2283,
|
||||
"indeedhub": 7778,
|
||||
"jellyfin": 8096,
|
||||
"lnd-ui": 18083,
|
||||
"mempool": 4080,
|
||||
"mempool-api": 8999,
|
||||
"morphos-server": 8089,
|
||||
"netbird": 8087,
|
||||
"nextcloud": 8085,
|
||||
"nostr-rs-relay": 18081,
|
||||
"photoprism": 2342,
|
||||
"pine": 10380,
|
||||
"portainer": 9000,
|
||||
"router": 8084,
|
||||
"searxng": 8888,
|
||||
"strfry": 8090,
|
||||
"uptime-kuma": 3002,
|
||||
"vaultwarden": 8082,
|
||||
}
|
||||
|
||||
export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"aiui": "AI Assistant",
|
||||
"archy-btcpay-db": "BTCPay Postgres",
|
||||
"archy-mempool-db": "Mempool MariaDB",
|
||||
"archy-mempool-web": "Mempool Web",
|
||||
"archy-nbxplorer": "NBXplorer",
|
||||
"barkd": "Ark Wallet",
|
||||
"bitcoin-core": "Bitcoin Core",
|
||||
"bitcoin-knots": "Bitcoin Knots",
|
||||
"bitcoin-ui": "Bitcoin UI",
|
||||
"botfights": "BotFights",
|
||||
"btcpay-server": "BTCPay Server",
|
||||
"core-lightning": "Core Lightning (CLN)",
|
||||
"did-wallet": "Web5 DID Wallet",
|
||||
"electrs-ui": "Electrs UI",
|
||||
"electrumx": "ElectrumX",
|
||||
"fedimint": "Fedimint Guardian",
|
||||
"fedimint-clientd": "Fedimint Client",
|
||||
"fedimint-gateway": "Fedimint Gateway",
|
||||
"filebrowser": "File Browser",
|
||||
"fips-ui": "FIPS Mesh",
|
||||
"gitea": "Gitea",
|
||||
"grafana": "Grafana",
|
||||
"homeassistant": "Home Assistant",
|
||||
"immich": "Immich",
|
||||
"immich-postgres": "Immich Postgres",
|
||||
"immich-redis": "Immich Redis",
|
||||
"indeedhub": "IndeeHub",
|
||||
"indeedhub-api": "IndeedHub API",
|
||||
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
|
||||
"indeedhub-minio": "IndeedHub MinIO",
|
||||
"indeedhub-postgres": "IndeedHub Postgres",
|
||||
"indeedhub-redis": "IndeedHub Redis",
|
||||
"indeedhub-relay": "IndeedHub Nostr Relay",
|
||||
"jellyfin": "Jellyfin",
|
||||
"lightning-stack": "Lightning Stack",
|
||||
"lnd": "LND",
|
||||
"lnd-ui": "LND UI",
|
||||
"mempool": "Mempool Explorer",
|
||||
"mempool-api": "Mempool API",
|
||||
"morphos-server": "MorphOS Server",
|
||||
"netbird": "NetBird",
|
||||
"netbird-dashboard": "NetBird Dashboard",
|
||||
"netbird-server": "NetBird Server",
|
||||
"nextcloud": "Nextcloud",
|
||||
"nostr-rs-relay": "Nostr Relay (Rust)",
|
||||
"photoprism": "PhotoPrism",
|
||||
"pine": "Pine",
|
||||
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
|
||||
"pine-piper": "Pine Piper (TTS)",
|
||||
"pine-whisper": "Pine Whisper (STT)",
|
||||
"portainer": "Portainer",
|
||||
"router": "Mesh Router",
|
||||
"searxng": "SearXNG",
|
||||
"strfry": "Strfry Nostr Relay",
|
||||
"uptime-kuma": "Uptime Kuma",
|
||||
"vaultwarden": "Vaultwarden",
|
||||
}
|
||||
|
||||
export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
||||
"btcpay-server",
|
||||
"gitea",
|
||||
"grafana",
|
||||
"homeassistant",
|
||||
"immich",
|
||||
"nextcloud",
|
||||
"photoprism",
|
||||
"pine",
|
||||
"portainer",
|
||||
"uptime-kuma",
|
||||
"vaultwarden",
|
||||
])
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Composable for managing app identity selection and NIP-07 identity injection */
|
||||
|
||||
import { type Ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
|
||||
export interface SelectedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
}
|
||||
|
||||
function isIdentityAwareApp(id: string): boolean {
|
||||
return id === 'indeedhub' || id === 'nostrudel'
|
||||
}
|
||||
|
||||
export function useAppIdentity(
|
||||
appId: Ref<string>,
|
||||
iframeRef: Ref<HTMLIFrameElement | null>,
|
||||
showIdentityPicker: Ref<boolean>,
|
||||
) {
|
||||
function getStoredIdentity(): SelectedIdentity | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(IDENTITY_KEY + appId.value)
|
||||
return stored ? JSON.parse(stored) as SelectedIdentity : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function storeIdentity(identity: SelectedIdentity) {
|
||||
try { localStorage.setItem(IDENTITY_KEY + appId.value, JSON.stringify(identity)) } catch {}
|
||||
}
|
||||
|
||||
async function sendIdentity(identity: SelectedIdentity) {
|
||||
try {
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({ method: 'identity.sign', params: { id: identity.id, message: challenge } })
|
||||
iframeRef.value?.contentWindow?.postMessage({
|
||||
type: 'archipelago:identity', did: identity.did, name: identity.name,
|
||||
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey || null,
|
||||
nostr_npub: identity.nostr_npub || null, challenge, signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
storeIdentity(identity)
|
||||
sendIdentity(identity)
|
||||
}
|
||||
|
||||
/** Called on iframe load to inject identity if the app supports it */
|
||||
function onIframeLoadIdentity() {
|
||||
if (isIdentityAwareApp(appId.value)) {
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle identity request messages from iframe */
|
||||
function handleIdentityRequest() {
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
getStoredIdentity,
|
||||
sendIdentity,
|
||||
onIdentitySelected,
|
||||
onIframeLoadIdentity,
|
||||
handleIdentityRequest,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Composable for NIP-07 Nostr signing bridge between parent and iframe */
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { SelectedIdentity } from './useAppIdentity'
|
||||
|
||||
export function useNostrBridge(
|
||||
getStoredIdentity: () => SelectedIdentity | null,
|
||||
getAppUrl: () => string,
|
||||
) {
|
||||
async function handleNostrRequest(event: MessageEvent) {
|
||||
const { id, method, params } = event.data
|
||||
const source = event.source as Window | null
|
||||
if (!source) return
|
||||
const storedIdentity = getStoredIdentity()
|
||||
const identityId = storedIdentity?.id || null
|
||||
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
if (method === 'getPublicKey') {
|
||||
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
|
||||
if (storedIdentity?.nostr_pubkey) {
|
||||
result = storedIdentity.nostr_pubkey
|
||||
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
|
||||
} else if (identityId) {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
|
||||
if (identityId) {
|
||||
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
|
||||
} else {
|
||||
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
}
|
||||
if (import.meta.env.DEV) console.log('[NIP-07] signEvent OK')
|
||||
} else if (method === 'getRelays') { result = {} }
|
||||
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin)
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
return { handleNostrRequest }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const APP_STORE_CATEGORIES = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'other', name: 'Other' },
|
||||
] as const
|
||||
|
||||
export const APP_STORE_SECTIONS = [
|
||||
{ id: 'discover', name: 'Discover' },
|
||||
...APP_STORE_CATEGORIES,
|
||||
] as const
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('goToApp', id)"
|
||||
@keydown.enter="handleEnter"
|
||||
>
|
||||
<!-- Installing indicator — no overlay, just replaces action buttons at bottom -->
|
||||
|
||||
<!-- Uninstalling — handled in button area below, no overlay -->
|
||||
|
||||
<!-- Uninstall Icon (not for web-only apps) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isUninstalling && !isInstalling && pkg.state !== 'installing'"
|
||||
@click.stop="$emit('showUninstall', id, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="title"
|
||||
class="app-card-icon archy-app-icon w-14 h-14"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h3 class="text-lg font-semibold text-white truncate" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="tier && tier !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tier }}</span>
|
||||
<span
|
||||
v-if="pkg['available-update']"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
|
||||
>Update</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p>
|
||||
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-white/70 text-sm mt-3 mb-3 line-clamp-2 min-h-[2.5rem]">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<svg
|
||||
v-if="isTransitioning"
|
||||
class="animate-spin h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
|
||||
{{ blockedReason }}
|
||||
</p>
|
||||
|
||||
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
||||
<!-- Installing progress — replaces action buttons -->
|
||||
<div v-if="isInstalling || pkg.state === 'installing'" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installProgress?.message || 'Installing...' }}
|
||||
</span>
|
||||
<span class="text-xs text-white/50">{{ Math.round(installProgress?.progress || 0) }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="install-progress-fill h-full bg-white/60 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${Math.max(installProgress?.progress || 2, 2)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstalling progress — truthful stage-driven bar (mirrors install) -->
|
||||
<div v-else-if="isUninstalling" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ uninstallStageLabel }}
|
||||
</span>
|
||||
<span v-if="uninstallProgress !== null" class="text-xs text-white/50">{{ uninstallProgress }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="install-progress-fill h-full bg-white/60 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${Math.max(uninstallProgress ?? 8, 4)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 flex gap-2">
|
||||
<!-- Update available -->
|
||||
<button
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@click.stop="$emit('update', id)"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
|
||||
:title="`Update to ${$ver(pkg['available-update'])}`"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.update') }}
|
||||
</button>
|
||||
<!-- Updating in progress -->
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('common.updating') }}
|
||||
</span>
|
||||
<!-- Launch -->
|
||||
<button
|
||||
v-if="canLaunch(pkg)"
|
||||
data-controller-launch-btn
|
||||
@click.stop="$emit('launch', id)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{{ t('common.launch') }}
|
||||
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" 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>
|
||||
</button>
|
||||
<!-- Start (play icon) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||
@click.stop="$emit('start', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="pkg.state === 'exited' ? 'Restart' : t('common.start')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
<!-- Starting (spinner) -->
|
||||
<button
|
||||
v-if="!isWebOnly && isLoading && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||||
disabled
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Stop (square icon) -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="$emit('stop', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="t('common.stop')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1" /></svg>
|
||||
</button>
|
||||
<!-- Restart -->
|
||||
<button
|
||||
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="$emit('restart', id)"
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
|
||||
:title="t('common.restart')"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Stopping (spinner) -->
|
||||
<button
|
||||
v-if="!isWebOnly && isLoading && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||||
disabled
|
||||
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Build a lookup map for enriching sparse backend data during install
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
pkg: PackageDataEntry
|
||||
index: number
|
||||
showStagger: boolean
|
||||
isLoading: boolean
|
||||
isInstalling?: boolean
|
||||
installProgress?: { status: string; progress: number; message: string }
|
||||
isUninstalling: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToApp: [id: string]
|
||||
launch: [id: string]
|
||||
start: [id: string]
|
||||
stop: [id: string]
|
||||
restart: [id: string]
|
||||
update: [id: string]
|
||||
showUninstall: [id: string, pkg: PackageDataEntry]
|
||||
}>()
|
||||
|
||||
function handleEnter(e: KeyboardEvent) {
|
||||
// Controller nav already handled this Enter (preventDefault was called) — skip to avoid double navigation
|
||||
if (e.defaultPrevented) return
|
||||
emit('goToApp', props.id)
|
||||
}
|
||||
|
||||
const isWebOnly = computed(() => isWebOnlyApp(props.id))
|
||||
|
||||
// Enrich from marketplace when backend data is sparse (e.g. during install)
|
||||
const curated = computed(() => curatedMap.get(props.id))
|
||||
const title = computed(() => {
|
||||
const t = props.pkg.manifest?.title
|
||||
return (t && t !== props.id) ? t : (curated.value?.title || t || props.id)
|
||||
})
|
||||
const description = computed(() => {
|
||||
const d = props.pkg.manifest?.description?.short
|
||||
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
|
||||
})
|
||||
const icon = computed(() => resolveAppIcon(props.id, props.pkg, curated.value?.icon))
|
||||
const version = computed(() => {
|
||||
const v = props.pkg.manifest?.version
|
||||
return v || curated.value?.version || ''
|
||||
})
|
||||
const author = computed(() => props.pkg.manifest?.author || curated.value?.author || '')
|
||||
const tier = computed(() => {
|
||||
const t = props.pkg.manifest?.tier
|
||||
if (t && t !== '') return t
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(props.id)) return 'core'
|
||||
if (recommended.includes(props.id)) return 'recommended'
|
||||
return 'optional'
|
||||
})
|
||||
|
||||
// Live uninstall stage from backend, with a sensible fallback so the
|
||||
// label is never blank between WS pushes.
|
||||
const uninstallStageLabel = computed(() => {
|
||||
const raw = props.pkg['uninstall-stage']
|
||||
return raw ? raw : `${t('common.uninstalling')}…`
|
||||
})
|
||||
|
||||
// Map the backend's uninstall-stage label to a truthful percentage so the bar
|
||||
// progresses through the teardown instead of sitting at a solid full(-red)
|
||||
// block. Backend stages (set_uninstall_stage):
|
||||
// "Stopping containers (X/N)" → 10–50% (linear over the stack)
|
||||
// "Cleaning up volumes" → 70%
|
||||
// "Removing app data" → 90%
|
||||
// Unknown/between pushes → null → the bar parks low and the shimmer overlay
|
||||
// (install-progress-fill) carries the motion, exactly like a fixed install phase.
|
||||
const uninstallProgress = computed<number | null>(() => {
|
||||
const raw = props.pkg['uninstall-stage'] || ''
|
||||
const m = raw.match(/\((\d+)\s*\/\s*(\d+)\)/)
|
||||
if (m) {
|
||||
const done = Number(m[1])
|
||||
const total = Number(m[2])
|
||||
if (total > 0) {
|
||||
return Math.round(10 + Math.min(done / total, 1) * 40)
|
||||
}
|
||||
}
|
||||
if (/volume/i.test(raw)) return 70
|
||||
if (/data/i.test(raw)) return 90
|
||||
return null
|
||||
})
|
||||
|
||||
const isTransitioning = computed(() => {
|
||||
const s = props.pkg.state
|
||||
const h = props.pkg.health
|
||||
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
||||
})
|
||||
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Shimmer overlay on the install progress bar so users see motion even
|
||||
* when the bar is parked at a fixed phase percentage (pulling-image can
|
||||
* take minutes, and podman doesn't give us byte-level progress). */
|
||||
.install-progress-fill {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.55) 0%,
|
||||
rgba(255, 255, 255, 0.9) 50%,
|
||||
rgba(255, 255, 255, 0.55) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: install-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes install-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Respect user motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.install-progress-fill {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<div class="app-icon-grid-wrap">
|
||||
<!-- Swipeable pages -->
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="app-icon-pages"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
class="app-icon-page"
|
||||
>
|
||||
<div
|
||||
v-for="([id, pkg]) in page"
|
||||
:key="id"
|
||||
class="app-icon-item"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="getTitle(id, pkg)"
|
||||
@pointerdown="startLongPress(id)"
|
||||
@pointerup="clearLongPress"
|
||||
@pointercancel="clearLongPress"
|
||||
@pointerleave="clearLongPress"
|
||||
@contextmenu.prevent="openAppOptions(id)"
|
||||
@click="handleTap(id, pkg)"
|
||||
@keydown.enter="handleTap(id, pkg)"
|
||||
@keydown.space.prevent="openAppOptions(id)"
|
||||
>
|
||||
<!-- Icon with status indicator -->
|
||||
<div class="app-icon-frame">
|
||||
<img
|
||||
:src="getIcon(id, pkg)"
|
||||
:alt="getTitle(id, pkg)"
|
||||
class="app-icon-img archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
v-if="pkg.state === 'running'"
|
||||
class="app-icon-status app-icon-status-running"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'exited'"
|
||||
class="app-icon-status app-icon-status-error"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'starting' || pkg.state === 'stopping' || pkg.state === 'installing'"
|
||||
class="app-icon-status app-icon-status-transition"
|
||||
></span>
|
||||
<!-- Installing overlay -->
|
||||
<div
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-installing"
|
||||
>
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Launching overlay — instant tap feedback while the app opens -->
|
||||
<div v-if="launchingId === id" class="app-icon-installing">
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||
<span
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-progress-label"
|
||||
:title="progressLabel(id, pkg)"
|
||||
>
|
||||
{{ progressLabel(id, pkg) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page dots -->
|
||||
<div v-if="pages.length > 1" class="app-icon-dots">
|
||||
<button
|
||||
v-for="(_, i) in pages"
|
||||
:key="i"
|
||||
class="app-icon-dot"
|
||||
:class="{ 'app-icon-dot-active': i === activePage }"
|
||||
:aria-label="`Page ${i + 1}`"
|
||||
@click="scrollToPage(i)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './appCredentials'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
apps: [string, PackageDataEntry][]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToApp: [id: string]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Per-icon "launching" spinner so a tap is acknowledged instantly even while
|
||||
// the app session/iframe is still spinning up. Cleared when the launcher
|
||||
// overlay opens, with a fallback timeout for the open-in-new-tab path.
|
||||
const launchingId = ref<string | null>(null)
|
||||
let launchClearTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function markLaunching(id: string) {
|
||||
launchingId.value = id
|
||||
if (launchClearTimer) clearTimeout(launchClearTimer)
|
||||
launchClearTimer = setTimeout(() => {
|
||||
if (launchingId.value === id) launchingId.value = null
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
// Clear the spinner as soon as the app overlay actually opens.
|
||||
watch(() => appLauncher.isOpen, (open) => {
|
||||
if (open) {
|
||||
launchingId.value = null
|
||||
if (launchClearTimer) { clearTimeout(launchClearTimer); launchClearTimer = null }
|
||||
}
|
||||
})
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
|
||||
result.push(props.apps.slice(i, i + ITEMS_PER_PAGE))
|
||||
}
|
||||
return result.length ? result : [[]]
|
||||
})
|
||||
|
||||
function getTitle(id: string, pkg: PackageDataEntry): string {
|
||||
const t = pkg.manifest?.title
|
||||
if (t && t !== id) return t
|
||||
return curatedMap.get(id)?.title || t || id
|
||||
}
|
||||
|
||||
function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||
}
|
||||
|
||||
function progressLabel(id: string, pkg: PackageDataEntry): string {
|
||||
const install = serverStore.installingApps.get(id)
|
||||
if (install) {
|
||||
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
|
||||
}
|
||||
if (serverStore.uninstallingApps.has(id)) {
|
||||
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (longPressTriggered.value) {
|
||||
longPressTriggered.value = false
|
||||
return
|
||||
}
|
||||
if (canLaunch(pkg)) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||
if (shown) return
|
||||
launchNow(id, pkg)
|
||||
} else {
|
||||
emit('goToApp', id)
|
||||
}
|
||||
}
|
||||
|
||||
function startLongPress(id: string) {
|
||||
clearLongPress()
|
||||
longPressTriggered.value = false
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTriggered.value = true
|
||||
openAppOptions(id)
|
||||
}, 550)
|
||||
}
|
||||
|
||||
function clearLongPress() {
|
||||
if (!longPressTimer) return
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
|
||||
function openAppOptions(id: string) {
|
||||
clearLongPress()
|
||||
emit('goToApp', id)
|
||||
}
|
||||
|
||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
markLaunching(id)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
appLauncher.openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredentialModal() { credentialModal.value.show = false }
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
const entry = props.apps.find(([appId]) => appId === id)
|
||||
closeCredentialModal()
|
||||
if (entry) launchNow(entry[0], entry[1])
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try { await navigator.clipboard.writeText(value) } catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
const pageWidth = el.clientWidth
|
||||
if (pageWidth === 0) return
|
||||
activePage.value = Math.round(el.scrollLeft / pageWidth)
|
||||
}
|
||||
|
||||
function scrollToPage(index: number) {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Instant press feedback: the icon scales down the moment it's touched, so the
|
||||
tap is acknowledged even before the app finishes launching. */
|
||||
.app-icon-frame {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.app-icon-item:active .app-icon-frame {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.sideload-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-close-btn:hover,
|
||||
.sideload-close-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="modalRef"
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="uninstall-dialog-title"
|
||||
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70">
|
||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||
</p>
|
||||
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="deleteAppData"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-white/30 bg-black/30 text-red-500 focus:ring-red-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
||||
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm', deleteAppData)"
|
||||
:disabled="uninstalling"
|
||||
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="uninstalling"
|
||||
class="animate-spin h-4 w-4"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
appTitle: string
|
||||
uninstalling: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [deleteAppData: boolean]
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
const deleteAppData = ref(false)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
computed(() => props.show),
|
||||
() => emit('close'),
|
||||
{ restoreFocusRef },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
deleteAppData.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="pb-16 md:pb-4">
|
||||
<BackButton :label="backLabel" desktop-margin="mb-6" @click="goBack" />
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
|
||||
|
||||
<LightningChannelsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// When a setup wizard sent us here (?from=goal&goal=<id>), back returns to it.
|
||||
const fromGoalId = computed(() =>
|
||||
route.query.from === 'goal' && typeof route.query.goal === 'string' ? route.query.goal : null,
|
||||
)
|
||||
const backLabel = computed(() => (fromGoalId.value ? 'Back to Setup' : 'Back to LND'))
|
||||
|
||||
function goBack() {
|
||||
if (fromGoalId.value) {
|
||||
router.push(`/dashboard/goals/${fromGoalId.value}`)
|
||||
} else {
|
||||
router.replace('/dashboard/apps/lnd')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import AppIconGrid from '../AppIconGrid.vue'
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppIconGrid', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 1024,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.198' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens LND companion UI in the app panel', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBe('lnd')
|
||||
})
|
||||
|
||||
it('shows File Browser credentials before launch even when backend returns no credentials', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
// The credential modal is <Teleport to="body">'d (so its full-screen
|
||||
// backdrop isn't clipped by the dashboard's transformed layout) —
|
||||
// stub it to render inline so wrapper.text() still sees it.
|
||||
stubs: { teleport: true },
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('File Browser credentials')
|
||||
expect(wrapper.text()).toContain('Username')
|
||||
expect(wrapper.text()).toContain('admin')
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('opens unresolved new-tab apps externally on mobile', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.198:3001',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('shows backend uninstall stage while an app is removing', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
pkg['uninstall-stage'] = 'Stopping containers (2/7)'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Stopping containers (2/7)')
|
||||
})
|
||||
|
||||
it('supports legacy underscore uninstall stage data', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
;(pkg as PackageDataEntry & { uninstall_stage?: string }).uninstall_stage = 'Removing app data'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Removing app data')
|
||||
})
|
||||
|
||||
it('opens app details on long press without launching the app', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
const icon = wrapper.get('.app-icon-item')
|
||||
await icon.trigger('pointerdown')
|
||||
vi.advanceTimersByTime(550)
|
||||
await icon.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('opens app details from the keyboard options shortcut', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('keydown.space')
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params?.name) return `${key} ${params.name}`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('AppsUninstallModal', () => {
|
||||
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
||||
const wrapper = mount(AppsUninstallModal, {
|
||||
props: {
|
||||
show: true,
|
||||
appTitle: 'File Browser',
|
||||
uninstalling: false,
|
||||
},
|
||||
})
|
||||
|
||||
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox).not.toBeNull()
|
||||
checkbox?.click()
|
||||
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
||||
expect(confirmButton).not.toBeNull()
|
||||
confirmButton?.click()
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LightningChannels from '@/components/LightningChannelsPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeChannel() {
|
||||
return {
|
||||
chan_id: '123',
|
||||
remote_pubkey: 'peer-pubkey',
|
||||
capacity: 100_000,
|
||||
local_balance: 60_000,
|
||||
remote_balance: 40_000,
|
||||
active: true,
|
||||
status: 'active',
|
||||
channel_point: 'txid:0',
|
||||
}
|
||||
}
|
||||
|
||||
describe('LightningChannels', () => {
|
||||
it('keeps channels visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
channels: [makeChannel()],
|
||||
total_inbound: 40_000,
|
||||
total_outbound: 60_000,
|
||||
})
|
||||
|
||||
const wrapper = mount(LightningChannels)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('100.0k sats')
|
||||
|
||||
const pending = deferred<{ channels: []; total_inbound: number; total_outbound: number }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadChannels: () => Promise<void> }).loadChannels()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('Refreshing channels...')
|
||||
expect(wrapper.text()).not.toContain('Loading channels...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveAppCredentials } from '../appCredentials'
|
||||
|
||||
describe('resolveAppCredentials', () => {
|
||||
it('uses backend credentials when they are available', () => {
|
||||
expect(resolveAppCredentials('filebrowser', {
|
||||
title: 'Backend credentials',
|
||||
credentials: [{ label: 'Password', value: 'secret' }],
|
||||
})?.credentials[0]?.value).toBe('secret')
|
||||
})
|
||||
|
||||
it('falls back to File Browser default credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('filebrowser', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('File Browser credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to PhotoPrism manifest credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('photoprism', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('PhotoPrism credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent credentials for unknown apps', () => {
|
||||
expect(resolveAppCredentials('unknown', { credentials: [] })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useLastKnownPackages, type PackageMap } from '../appPackageCache'
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useLastKnownPackages', () => {
|
||||
it('keeps the last package list visible while the scanner reports not ready', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
|
||||
containersScanned.value = false
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an empty list once the scanner has completed', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(cache.packages.value).toEqual({})
|
||||
expect(cache.lastKnownPackages.value).toEqual({})
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
category,
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('appsConfig service filtering', () => {
|
||||
it('treats bitcoin stack UI sidecars as services', () => {
|
||||
expect(isServiceContainer('bitcoin-ui')).toBe(true)
|
||||
expect(isServiceContainer('lnd-ui')).toBe(true)
|
||||
expect(isServiceContainer('electrs-ui')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats container aliases as services even with non-service keys', () => {
|
||||
const aliasPkg = makePkg('bitcoin-ui', 'Bitcoin UI', 'money')
|
||||
expect(isServicePackage('core-lnd-ui', aliasPkg)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes service-only categories from app category tabs', () => {
|
||||
const packages = ref<Record<string, PackageDataEntry>>({
|
||||
'core-bitcoin-ui': makePkg('bitcoin-ui', 'Bitcoin UI', 'money'),
|
||||
'filebrowser': makePkg('filebrowser', 'File Browser', 'data'),
|
||||
})
|
||||
|
||||
const allCategories = ref([
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
])
|
||||
|
||||
const visible = useCategoriesWithApps(packages, allCategories)
|
||||
expect(visible.value.map(c => c.id)).toEqual(['all', 'data'])
|
||||
})
|
||||
|
||||
it('filters apps tab by category using manifest-aware service checks', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-bitcoin-ui', makePkg('bitcoin-ui', 'Bitcoin UI', 'money')],
|
||||
['filebrowser', makePkg('filebrowser', 'File Browser', 'data')],
|
||||
['btcpay-server', makePkg('btcpay-server', 'BTCPay', 'commerce')],
|
||||
]
|
||||
|
||||
const appsAll = filterEntriesForTab(entries, 'apps', 'all')
|
||||
expect(appsAll.map(([id]) => id)).toEqual(['filebrowser', 'btcpay-server'])
|
||||
|
||||
const appsData = filterEntriesForTab(entries, 'apps', 'data')
|
||||
expect(appsData.map(([id]) => id)).toEqual(['filebrowser'])
|
||||
})
|
||||
|
||||
it('routes service aliases into services tab and excludes user apps', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-lnd-ui', makePkg('lnd-ui', 'LND UI', 'money')],
|
||||
['grafana', makePkg('grafana', 'Grafana', 'data')],
|
||||
]
|
||||
|
||||
const services = filterEntriesForTab(entries, 'services', 'all')
|
||||
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||
})
|
||||
|
||||
it('classifies an unknown app by whether its manifest declares a UI (#45)', () => {
|
||||
// Headless: a LAN address but no declared UI → Website.
|
||||
const headless = makePkg('some-backend', 'Some Backend', 'other')
|
||||
headless.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9000' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(hasFrontendUi(headless)).toBe(false)
|
||||
expect(isWebsitePackage('some-backend', headless)).toBe(true)
|
||||
|
||||
// Front-end app: declares interfaces.main.ui → My Apps even when not in the
|
||||
// curated category map.
|
||||
const uiApp = makePkg('some-ui-app', 'Some UI App', 'other')
|
||||
;(uiApp.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:9001' } }
|
||||
uiApp.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9001' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(hasFrontendUi(uiApp)).toBe(true)
|
||||
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
|
||||
})
|
||||
|
||||
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||
pkg.state = PackageState.Starting
|
||||
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8175' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(launchBlockedReason('fedimint', pkg)).toContain('Bitcoin')
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { parseSideloadPortMapping, validateSideloadRequest } from '../sideloadValidation'
|
||||
|
||||
function makePkg(id: string, title: string, lanAddress?: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
installed: lanAddress
|
||||
? {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
status: 'running',
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'lan-address': lanAddress,
|
||||
'tor-address': '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sideloadValidation', () => {
|
||||
it('parses host and container port mappings', () => {
|
||||
expect(parseSideloadPortMapping('3009:80')).toEqual({ host: 3009, container: 80 })
|
||||
expect(parseSideloadPortMapping('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed port mappings', () => {
|
||||
expect(() => parseSideloadPortMapping('3009')).toThrow('host:container')
|
||||
expect(() => parseSideloadPortMapping('99999:80')).toThrow('between 1 and 65535')
|
||||
})
|
||||
|
||||
it('rejects duplicate app IDs', () => {
|
||||
const packages = { excalidraw: makePkg('excalidraw', 'Excalidraw') }
|
||||
expect(validateSideloadRequest('excalidraw', '3009:80', packages)).toContain('already installed')
|
||||
})
|
||||
|
||||
it('rejects reserved host ports', () => {
|
||||
expect(validateSideloadRequest('demo', '9000:80', {})).toContain('reserved')
|
||||
})
|
||||
|
||||
it('rejects host ports already used by installed apps', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '8083:80', packages)).toContain('File Browser')
|
||||
})
|
||||
|
||||
it('accepts available host ports', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '3018:80', packages)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
const FALLBACK_CREDENTIALS: Record<string, AppCredentialsResponse> = {
|
||||
filebrowser: {
|
||||
title: 'File Browser credentials',
|
||||
description: 'Use these credentials when File Browser asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
],
|
||||
},
|
||||
photoprism: {
|
||||
title: 'PhotoPrism credentials',
|
||||
description: 'Use these credentials when PhotoPrism asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveAppCredentials(appId: string, response?: AppCredentialsResponse | null): AppCredentialsResponse | null {
|
||||
if (response?.credentials?.length) return response
|
||||
return FALLBACK_CREDENTIALS[appId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
export type PackageMap = Record<string, PackageDataEntry>
|
||||
|
||||
export function useLastKnownPackages(
|
||||
livePackages: Ref<PackageMap>,
|
||||
containersScanned: Ref<boolean>,
|
||||
) {
|
||||
const lastKnownPackages = ref<PackageMap>({})
|
||||
|
||||
watch(
|
||||
livePackages,
|
||||
(packages) => {
|
||||
const hasPackages = Object.keys(packages).length > 0
|
||||
if (hasPackages || containersScanned.value) {
|
||||
lastKnownPackages.value = { ...packages }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const isUsingLastKnownPackages = computed(() => (
|
||||
!containersScanned.value &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
Object.keys(lastKnownPackages.value).length > 0
|
||||
))
|
||||
|
||||
const packages = computed<PackageMap>(() => (
|
||||
isUsingLastKnownPackages.value ? lastKnownPackages.value : livePackages.value
|
||||
))
|
||||
|
||||
return {
|
||||
packages,
|
||||
isUsingLastKnownPackages,
|
||||
lastKnownPackages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/** Static configuration for the Apps view */
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
])
|
||||
|
||||
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id || ''
|
||||
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
||||
}
|
||||
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isServiceContainer(id)) return true
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!manifestId && isServiceContainer(manifestId)
|
||||
}
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home', 'pine': 'home',
|
||||
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
|
||||
'nostrudel': 'nostr',
|
||||
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
||||
'uptime-kuma': 'networking',
|
||||
'botfights': 'community',
|
||||
}
|
||||
|
||||
export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
||||
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
||||
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
||||
return cat || 'other'
|
||||
}
|
||||
|
||||
export function runtimeLanAddress(pkg: PackageDataEntry): string {
|
||||
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
|
||||
}
|
||||
|
||||
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
|
||||
}
|
||||
|
||||
// True when the package's manifest declares a front-end UI interface. This is
|
||||
// the authoritative "is this a user-facing app?" signal (#45/#51): apps with a
|
||||
// UI belong in "My Apps", while headless services (databases, APIs, backends,
|
||||
// workers) declare no UI and belong in the "Services" tab.
|
||||
export function hasFrontendUi(pkg?: PackageDataEntry): boolean {
|
||||
return !!pkg?.manifest?.interfaces?.main?.ui
|
||||
}
|
||||
|
||||
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
// Headless infra (databases/backends/companions) keyed by container name are
|
||||
// services regardless of any stray UI string.
|
||||
if (isServicePackage(id, pkg)) return true
|
||||
// A declared front-end UI is the deciding factor: it's an app, not a website.
|
||||
if (hasFrontendUi(pkg)) return false
|
||||
// Curated known apps stay in My Apps even if their manifest predates the UI
|
||||
// interface field.
|
||||
if (isKnownApp(id, pkg)) return false
|
||||
// Anything still here has no declared UI and isn't a known launcher app:
|
||||
// databases, APIs, backends, workers. They belong in Services (not My Apps),
|
||||
// whether or not they expose a LAN address. (#10 — "anything that isn't the
|
||||
// frontend UI launcher".)
|
||||
return !!pkg
|
||||
}
|
||||
|
||||
export function filterEntriesForTab(
|
||||
entries: Array<[string, PackageDataEntry]>,
|
||||
activeTab: AppsTab,
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
return entries.filter(([id, pkg]) => {
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
||||
const isWebsite = isWebsitePackage(id, pkg)
|
||||
if (wantsWebsites ? !isWebsite : isWebsite) return false
|
||||
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
if (activeTab === 'services' && selectedCategory !== 'all') {
|
||||
return getServiceCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Group a (non-launcher) service container by type for the Services tab sub-nav
|
||||
// (#12). Heuristic over the container id + manifest id.
|
||||
export function getServiceCategory(id: string, pkg?: PackageDataEntry): string {
|
||||
const s = `${id} ${pkg?.manifest?.id || ''}`.toLowerCase()
|
||||
if (/postgres|mariadb|mysql|(^|[-_])db([-_]|$)/.test(s)) return 'database'
|
||||
if (/redis|valkey|(^|[-_])cache([-_]|$)/.test(s)) return 'cache'
|
||||
if (/(^|[-_])api([-_]|$)/.test(s)) return 'api'
|
||||
return 'backend'
|
||||
}
|
||||
|
||||
export function buildServiceCategories(t: (key: string) => string): Array<{ id: string; name: string }> {
|
||||
return [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'database', name: 'Databases' },
|
||||
{ id: 'cache', name: 'Caches' },
|
||||
{ id: 'api', name: 'APIs' },
|
||||
{ id: 'backend', name: 'Backends' },
|
||||
]
|
||||
}
|
||||
|
||||
// Web-only app IDs and their URLs
|
||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
|
||||
|
||||
export function isWebOnlyApp(id: string): boolean {
|
||||
return id in WEB_ONLY_APP_URLS
|
||||
}
|
||||
|
||||
// Web-only apps (no container) -- always show as installed bookmarks
|
||||
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {}
|
||||
|
||||
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
|
||||
export const TAB_LAUNCH_APPS = new Set([
|
||||
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
||||
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
|
||||
'netbird',
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
}
|
||||
|
||||
// Backend services that ship no icon of their own reuse their PARENT app's icon
|
||||
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
|
||||
// are explicit because icon extensions vary (.png / .webp / .svg).
|
||||
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
gitea: '/assets/img/app-icons/gitea.svg',
|
||||
// Apps whose icon extension isn't .png: without an explicit entry the
|
||||
// default `<id>.png` guess 404s on every render (console spam, and for
|
||||
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
|
||||
pine: '/assets/img/app-icons/pine.svg',
|
||||
mempool: '/assets/img/app-icons/mempool.webp',
|
||||
'mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
||||
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
||||
// immich stack
|
||||
'immich-postgres': '/assets/img/app-icons/immich.png',
|
||||
'immich-redis': '/assets/img/app-icons/immich.png',
|
||||
'immich-server': '/assets/img/app-icons/immich.png',
|
||||
'immich_postgres': '/assets/img/app-icons/immich.png',
|
||||
'immich_redis': '/assets/img/app-icons/immich.png',
|
||||
// btcpay stack
|
||||
'archy-btcpay-db': '/assets/img/app-icons/btcpay-server.png',
|
||||
'archy-nbxplorer': '/assets/img/app-icons/btcpay-server.png',
|
||||
// mempool stack
|
||||
'archy-mempool-db': '/assets/img/app-icons/mempool.webp',
|
||||
'mempool-api': '/assets/img/app-icons/mempool.webp',
|
||||
'archy-mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||
'mysql-mempool': '/assets/img/app-icons/mempool.webp',
|
||||
// bitcoin / lightning companion UIs
|
||||
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
'archy-lnd-ui': '/assets/img/app-icons/lnd.png',
|
||||
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
// ElectrumX ships under a few historical ids (the backend was renamed
|
||||
// electrs → electrumx). Pin the whole family to the ElectrumX icon so My
|
||||
// Apps shows the right logo no matter which id the node has it installed
|
||||
// under.
|
||||
'electrs': '/assets/img/app-icons/electrumx.png',
|
||||
'electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
'electrumx': '/assets/img/app-icons/electrumx.png',
|
||||
}
|
||||
|
||||
// Parent-app icon by prefix, for stack members not listed explicitly above
|
||||
// (e.g. every indeedhub-* sub-container → indeedhub).
|
||||
const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
|
||||
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
|
||||
['immich-', '/assets/img/app-icons/immich.png'],
|
||||
['immich_', '/assets/img/app-icons/immich.png'],
|
||||
['pine-', '/assets/img/app-icons/pine.svg'],
|
||||
]
|
||||
|
||||
function serviceParentIcon(id: string): string | undefined {
|
||||
for (const [prefix, icon] of SERVICE_ICON_PREFIXES) {
|
||||
if (id.startsWith(prefix)) return icon
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||
if (
|
||||
icon.startsWith("/") ||
|
||||
icon.startsWith("http://") ||
|
||||
icon.startsWith("https://") ||
|
||||
icon.startsWith("data:image")
|
||||
) {
|
||||
return icon
|
||||
}
|
||||
return (
|
||||
curatedIcon ||
|
||||
APP_ICON_FALLBACKS[id] ||
|
||||
serviceParentIcon(id) ||
|
||||
`/assets/img/app-icons/${id}.png`
|
||||
)
|
||||
}
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||
}
|
||||
// A static launch URL (e.g. a host-networked companion UI like
|
||||
// archy-electrs-ui) serves independently of the backend's own sync state, so
|
||||
// the tile stays launchable while the backend is still 'starting' (ElectrumX
|
||||
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
|
||||
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
|
||||
const blockedByHealth =
|
||||
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
|
||||
return !!hasUI && pkg.state === 'running' && !blockedByHealth
|
||||
}
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (
|
||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||
) {
|
||||
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
case PackageState.Exited:
|
||||
// Exit code 0 = clean shutdown (gray), non-zero = crash (red)
|
||||
return exitCode != null && exitCode !== 0
|
||||
? 'bg-red-500/20 text-red-200'
|
||||
: 'bg-gray-500/20 text-gray-200'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200'
|
||||
case PackageState.Updating:
|
||||
return 'bg-orange-500/20 text-orange-200'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||
if (state === PackageState.Updating) return 'updating...'
|
||||
if (state === PackageState.Running) return 'running'
|
||||
if (state === PackageState.Exited || state === PackageState.Stopped) {
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function buildAllCategories(t: (key: string) => string) {
|
||||
return [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'media', name: 'Media' },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'other', name: t('marketplace.other') },
|
||||
]
|
||||
}
|
||||
|
||||
export function useCategoriesWithApps(
|
||||
packages: Ref<Record<string, PackageDataEntry>>,
|
||||
allCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
||||
return allCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Services-tab equivalent of useCategoriesWithApps: only show a service category
|
||||
// when at least one installed service belongs to it (#12).
|
||||
export function useServiceCategories(
|
||||
packages: Ref<Record<string, PackageDataEntry>>,
|
||||
serviceCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
||||
return serviceCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getServiceCategory(id, pkg) === cat.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
const currentSrc = target.src
|
||||
|
||||
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
|
||||
target.dataset.fallbackTried = "1"
|
||||
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = "1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
const RESERVED_HOST_PORTS = new Set([
|
||||
80, 443, 81,
|
||||
8332, 8333, 8334,
|
||||
9735, 10009, 8080,
|
||||
18083,
|
||||
4080, 8999, 50001,
|
||||
23000,
|
||||
8173, 8174, 8175,
|
||||
8123,
|
||||
3000,
|
||||
11434,
|
||||
9980, 9001,
|
||||
8240,
|
||||
9000,
|
||||
3001, 3002,
|
||||
8888,
|
||||
8096, 2342, 2283,
|
||||
8443,
|
||||
])
|
||||
|
||||
export interface ParsedPortMapping {
|
||||
host: number
|
||||
container: number
|
||||
}
|
||||
|
||||
export function parseSideloadPortMapping(value: string): ParsedPortMapping | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const match = trimmed.match(/^(\d{1,5}):(\d{1,5})$/)
|
||||
if (!match) {
|
||||
throw new Error('Port mapping must use host:container format, for example 3009:80.')
|
||||
}
|
||||
|
||||
const host = Number(match[1])
|
||||
const container = Number(match[2])
|
||||
if (!Number.isInteger(host) || host < 1 || host > 65535 || !Number.isInteger(container) || container < 1 || container > 65535) {
|
||||
throw new Error('Ports must be between 1 and 65535.')
|
||||
}
|
||||
return { host, container }
|
||||
}
|
||||
|
||||
export function packageUsesHostPort(pkg: PackageDataEntry, hostPort: number): boolean {
|
||||
const addresses = pkg.installed?.['interface-addresses'] || {}
|
||||
return Object.values(addresses).some((addr) => {
|
||||
const lan = addr?.['lan-address']
|
||||
if (!lan) return false
|
||||
try {
|
||||
const parsed = new URL(lan)
|
||||
return Number(parsed.port || (parsed.protocol === 'https:' ? '443' : '80')) === hostPort
|
||||
} catch {
|
||||
const match = lan.match(/:(\d+)(?:\/|$)/)
|
||||
return match ? Number(match[1]) === hostPort : false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSideloadRequest(
|
||||
id: string,
|
||||
portMapping: string,
|
||||
packages: Record<string, PackageDataEntry>,
|
||||
): string | null {
|
||||
if (packages[id]) return `An app with ID "${id}" is already installed.`
|
||||
|
||||
let parsed: ParsedPortMapping | null = null
|
||||
try {
|
||||
parsed = parseSideloadPortMapping(portMapping)
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid port mapping.'
|
||||
}
|
||||
if (!parsed) return null
|
||||
|
||||
if (RESERVED_HOST_PORTS.has(parsed.host)) {
|
||||
return `Host port ${parsed.host} is reserved by Archipelago or a packaged app. Choose another host port.`
|
||||
}
|
||||
|
||||
const existing = Object.entries(packages).find(([, pkg]) => packageUsesHostPort(pkg, parsed.host))
|
||||
if (existing) {
|
||||
const title = existing[1].manifest?.title || existing[0]
|
||||
return `Host port ${parsed.host} is already used by ${title}. Choose another host port.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/** Composable for app start/stop/restart/uninstall actions */
|
||||
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
|
||||
export function useAppsActions() {
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const loadingActions = ref<Record<string, boolean>>({})
|
||||
const actionError = ref('')
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const actionTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const uninstalling = ref(false)
|
||||
// Use global store so uninstall state persists across navigation
|
||||
const uninstallingApps = serverStore.uninstallingApps
|
||||
|
||||
function showActionError(msg: string) {
|
||||
actionError.value = msg
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
async function startApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.startPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 5000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to start app:', err)
|
||||
showActionError(`Failed to start app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.stopPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 5000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
|
||||
showActionError(`Failed to stop app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.restartPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 8000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
|
||||
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
|
||||
uninstalling.value = true
|
||||
try {
|
||||
uninstallingApps.add(appId)
|
||||
await store.uninstallPackage(appId, options)
|
||||
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||
// when the container actually disappears from backend data
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
uninstallingApps.delete(appId)
|
||||
} finally {
|
||||
uninstalling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of actionTimers.values()) clearTimeout(t)
|
||||
actionTimers.clear()
|
||||
if (errorTimer) clearTimeout(errorTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
loadingActions,
|
||||
actionError,
|
||||
uninstalling,
|
||||
uninstallingApps,
|
||||
showActionError,
|
||||
startApp,
|
||||
stopApp,
|
||||
restartApp,
|
||||
confirmUninstall,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function normalizeCloudPath(path: unknown, fallback = '/'): string {
|
||||
if (typeof path !== 'string' || !path.trim()) return fallback
|
||||
const trimmed = path.trim()
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
return withSlash.replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
export function parentCloudPath(path: string): string {
|
||||
const normalized = normalizeCloudPath(path)
|
||||
if (normalized === '/') return '/'
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf('/')) || '/'
|
||||
return parent
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Lifecycle / Offline Banner.
|
||||
Server restart/shutdown is deliberate → shown immediately. A plain
|
||||
connection blip is debounced (showConnIssue) so transient sub-grace
|
||||
reconnects don't flash. -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="(showLifecycle || showConnectionLost)"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Reconnecting Banner (debounced) -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="showReconnecting"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span class="font-medium">Reconnecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const isOffline = computed(() => store.isOffline)
|
||||
const isRestarting = computed(() => store.isRestarting)
|
||||
const isShuttingDown = computed(() => store.isShuttingDown)
|
||||
|
||||
// A deliberate server lifecycle transition (restart/shutdown) is real and
|
||||
// user-initiated — surface it immediately, no debounce.
|
||||
const isLifecycleTransition = computed(() => isRestarting.value || isShuttingDown.value)
|
||||
const showLifecycle = computed(() => isLifecycleTransition.value && store.isAuthenticated)
|
||||
|
||||
// A plain connection blip (offline or reconnecting, not a lifecycle transition).
|
||||
// The overwhelming majority recover within a second or two (load spikes,
|
||||
// Tailscale/relay TCP resets), so showing the banner instantly makes a healthy
|
||||
// node read as unstable. Debounce: only surface after the issue persists past a
|
||||
// grace window; hide immediately on recovery.
|
||||
const hasConnIssue = computed(
|
||||
() => (store.isReconnecting || isOffline.value) && !isLifecycleTransition.value
|
||||
)
|
||||
|
||||
const SHOW_DELAY_MS = 2500
|
||||
// Right after the page loads or the tab returns to the foreground, a dead
|
||||
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
|
||||
// first paint races the initial connect). Reconnecting takes longer than
|
||||
// the steady-state grace on real links — radio wake-up, TLS, proxies — so
|
||||
// the 2.5s window made every tab-return flash "Connection lost" on a
|
||||
// perfectly healthy node. Give those moments a much longer runway; keep
|
||||
// the short window for genuine mid-session drops.
|
||||
const RESUME_GRACE_WINDOW_MS = 15000
|
||||
const RESUME_SHOW_DELAY_MS = 10000
|
||||
const showConnIssue = ref(false)
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
|
||||
|
||||
function onVisibilityResume() {
|
||||
if (!document.hidden) lastResumeAt = Date.now()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityResume)
|
||||
|
||||
function clearTimer() {
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
hasConnIssue,
|
||||
(issue) => {
|
||||
clearTimer()
|
||||
// The demo runs against a local mock — a connection banner there is
|
||||
// meaningless noise on what should be a flawless showcase.
|
||||
if (IS_DEMO) return
|
||||
if (issue) {
|
||||
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
|
||||
? RESUME_SHOW_DELAY_MS
|
||||
: SHOW_DELAY_MS
|
||||
pendingTimer = setTimeout(() => {
|
||||
showConnIssue.value = true
|
||||
pendingTimer = null
|
||||
}, delay)
|
||||
} else {
|
||||
// Recovered before the grace window elapsed — hide at once.
|
||||
showConnIssue.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibilityResume)
|
||||
})
|
||||
|
||||
// Debounced visual states the template renders.
|
||||
const showReconnecting = computed(
|
||||
() => showConnIssue.value && store.isReconnecting && store.isAuthenticated
|
||||
)
|
||||
const showConnectionLost = computed(
|
||||
() => showConnIssue.value && isOffline.value && !store.isReconnecting && store.isAuthenticated
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Float the connection banners over the UI instead of occupying layout space
|
||||
* (which previously pushed the whole dashboard down when reconnecting).
|
||||
* Pinned top-center, clear of the status bar via the safe-area inset that the
|
||||
* Android companion app injects (--safe-area-top), falling back to env(). */
|
||||
.conn-banner-overlay {
|
||||
position: fixed;
|
||||
top: calc(1rem + var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
left: 50%;
|
||||
z-index: 60;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 2rem);
|
||||
pointer-events: none; /* purely informational — never intercept taps */
|
||||
}
|
||||
|
||||
.conn-banner-enter-active,
|
||||
.conn-banner-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.conn-banner-enter-from,
|
||||
.conn-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
}
|
||||
.conn-banner-enter-to,
|
||||
.conn-banner-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||
<div
|
||||
v-if="showAppsTabs && !isAppSessionActive"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
|
||||
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0); padding-top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
|
||||
>
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
|
||||
>My Apps</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
|
||||
>App Store</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps?tab=services"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'services' } })"
|
||||
>Services</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Network/Cloud -->
|
||||
<div
|
||||
v-if="showNetworkTabs && !isAppSessionActive"
|
||||
class="md:hidden fixed left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
|
||||
:class="{ 'glass-throw-mobile-tabs-2': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
:style="{ top: showAppsTabs ? '80px' : '0', paddingTop: showAppsTabs ? '16px' : 'calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px)' }"
|
||||
>
|
||||
<div class="mode-switcher mode-switcher-full">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/web5' || route.path.startsWith('/dashboard/web5/') }"
|
||||
>Web5</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/') }"
|
||||
>Cloud</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/') }"
|
||||
>Network</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/mesh"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/mesh' || route.path.startsWith('/dashboard/mesh/') }"
|
||||
>Mesh</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Bottom Tab Bar (hidden when app is open fullscreen) -->
|
||||
<nav
|
||||
v-if="!isAppSessionActive"
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
:aria-label="t('dashboard.mobileNav')"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: var(--safe-area-bottom, env(safe-area-inset-bottom, 0px));"
|
||||
>
|
||||
<div class="flex justify-around items-center px-2 py-3 relative">
|
||||
<RouterLink
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
aria-current-value="page"
|
||||
@click="appLauncher.closePanel()"
|
||||
class="flex items-center justify-center w-14 h-14 rounded-xl text-white/70 transition-all duration-300 relative z-10"
|
||||
:class="{
|
||||
'nav-tab-active': item.isCombined
|
||||
? (item.path === '/dashboard/apps'
|
||||
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
|
||||
: item.path === '/dashboard/web5'
|
||||
? (route.path.includes('/web5') || route.path.includes('/federation') || route.path.includes('/mesh'))
|
||||
: (route.path.includes('/cloud') || route.path.includes('/server')))
|
||||
: undefined
|
||||
}"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
>
|
||||
<svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<!-- Chat launcher -->
|
||||
<button
|
||||
@click="router.push('/dashboard/chat')"
|
||||
class="chat-launcher-btn-mobile flex items-center justify-center w-14 h-14 rounded-xl transition-all duration-300 relative z-10"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
isCombined?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
showZoomIn: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const uiMode = useUIModeStore()
|
||||
|
||||
const mobileTabBar = ref<HTMLElement | null>(null)
|
||||
const MOBILE_LAYOUT_MAX_WIDTH = 920
|
||||
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
|
||||
|
||||
// App sessions own their mobile controls, so the nav hides while one is open.
|
||||
// Mobile launches now use the store-driven panel (no route change) to keep the
|
||||
// background tab intact, so treat an active panel the same as a routed session.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
|
||||
return route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover')
|
||||
})
|
||||
|
||||
// Show persistent tabs for Network/Cloud on mobile
|
||||
const showNetworkTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
|
||||
if (route.name === 'cloud-folder') return false
|
||||
return route.path.includes('/server') || route.path.includes('/cloud') || route.path.includes('/web5') || route.path.includes('/mesh')
|
||||
})
|
||||
|
||||
// Top padding for content div to clear fixed mobile tab overlays.
|
||||
// Includes safe area inset for Android (read from CSS custom property set by WebView).
|
||||
const safeAreaTop = ref(0)
|
||||
|
||||
function readSafeAreaTop() {
|
||||
if (typeof window === 'undefined') return
|
||||
const val = getComputedStyle(document.documentElement).getPropertyValue('--safe-area-top').trim()
|
||||
if (val) safeAreaTop.value = parseInt(val, 10) || 0
|
||||
}
|
||||
|
||||
const mobileTabPaddingTop = computed(() => {
|
||||
if (typeof window === 'undefined' || viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return 0
|
||||
const sat = safeAreaTop.value
|
||||
if (showAppsTabs.value && showNetworkTabs.value) return 160 + sat
|
||||
if (showAppsTabs.value || showNetworkTabs.value) return 80 + sat
|
||||
return 0
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
showAppsTabs,
|
||||
showNetworkTabs,
|
||||
mobileTabPaddingTop,
|
||||
})
|
||||
|
||||
function updateTabBarHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
const el = mobileTabBar.value
|
||||
// offsetHeight is 0 when the bar is hidden (desktop `md:hidden`) or not yet
|
||||
// laid out. Writing `--mobile-tab-bar-height: 0px` would DEFEAT the `, 88px`
|
||||
// fallback baked into the `.mobile-scroll-pad` clearance calc (an explicit
|
||||
// 0px is still "set"), so the fixed tab bar ends up covering the last row of
|
||||
// content — the Cloud/files "bottom elements cut off" bug. Only write a real
|
||||
// measured height; otherwise remove the var so the fallback applies.
|
||||
if (el && el.offsetHeight > 0) {
|
||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${el.offsetHeight}px`)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
// Re-measure after the first paint: on mount the bar may not have its final
|
||||
// laid-out height yet (fonts/safe-area padding still settling), which would
|
||||
// leave the clearance var short.
|
||||
requestAnimationFrame(updateTabBarHeight)
|
||||
readSafeAreaTop()
|
||||
window.addEventListener('resize', onResize)
|
||||
// Re-read after WebView injection has had time to run. The injected
|
||||
// safe-area-bottom padding changes the bar's height, so re-measure too.
|
||||
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
// Re-measure on route changes
|
||||
watch(() => route.path, () => {
|
||||
nextTick(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
})
|
||||
|
||||
const gamerMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5', isCombined: true },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const easyMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatMobileNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const mobileNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyMobileNav
|
||||
if (uiMode.isChat) return chatMobileNav
|
||||
return gamerMobileNav
|
||||
})
|
||||
|
||||
function getIconPath(iconName: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
|
||||
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
|
||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
||||
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
||||
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
|
||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
settings: [
|
||||
'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',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
],
|
||||
}
|
||||
return icons[iconName] || []
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<aside
|
||||
v-show="!chatFullscreen"
|
||||
data-controller-zone="sidebar"
|
||||
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
|
||||
:class="{ 'sidebar-animate': showZoomIn }"
|
||||
>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner flex flex-col h-full min-h-0">
|
||||
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">{{ $ver(version) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
aria-current-value="page"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
@click="appLauncher.closePanel()"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
|
||||
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
|
||||
>{{ web5Badge.pendingRequestCount }}</span>
|
||||
<span
|
||||
v-if="item.path === '/dashboard/mesh' && meshStore.totalUnread > 0"
|
||||
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
|
||||
>{{ meshStore.totalUnread }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Chat launcher button -->
|
||||
<button
|
||||
@click="router.push('/dashboard/chat')"
|
||||
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
<span>AIUI</span>
|
||||
</button>
|
||||
|
||||
<!-- Logout - styled as nav item, below Settings -->
|
||||
<button
|
||||
@click="$emit('logout')"
|
||||
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom shrink-0">
|
||||
<div class="sidebar-controller px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
<CompanionIndicator />
|
||||
</div>
|
||||
|
||||
<!-- Online status -->
|
||||
<div class="px-6 pb-2">
|
||||
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
|
||||
<OnlineStatusPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="px-6 pb-6">
|
||||
<ModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useWeb5BadgeStore } from '@/stores/web5Badge'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import CompanionIndicator from '@/components/CompanionIndicator.vue'
|
||||
import ModeSwitcher from '@/components/ModeSwitcher.vue'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
isCombined?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
showZoomIn: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const uiMode = useUIModeStore()
|
||||
const web5Badge = useWeb5BadgeStore()
|
||||
const meshStore = useMeshStore()
|
||||
|
||||
const chatFullscreen = computed(() => route.path === '/dashboard/chat')
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
|
||||
const gamerDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
|
||||
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
|
||||
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
|
||||
// { path: '/dashboard/fleet', label: 'Fleet', icon: 'fleet' }, // Hidden for beta
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const easyDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const chatDesktopNav: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Home', icon: 'home' },
|
||||
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
const desktopNavItems = computed(() => {
|
||||
if (uiMode.isEasy) return easyDesktopNav
|
||||
if (uiMode.isChat) return chatDesktopNav
|
||||
return gamerDesktopNav
|
||||
})
|
||||
|
||||
function getIconPath(iconName: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
|
||||
marketplace: ['M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z'],
|
||||
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
|
||||
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
|
||||
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
|
||||
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
|
||||
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
|
||||
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
settings: [
|
||||
'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',
|
||||
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
],
|
||||
}
|
||||
return icons[iconName] || []
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="healthNotifications.length > 0"
|
||||
class="fixed right-4 z-[200] flex flex-col gap-2 max-w-sm"
|
||||
style="top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
|
||||
>
|
||||
<div
|
||||
v-for="notif in healthNotifications"
|
||||
:key="notif.id"
|
||||
class="p-3 rounded-xl border backdrop-blur-lg shadow-lg"
|
||||
:class="notif.level === 'error'
|
||||
? 'bg-red-500/15 border-red-500/30'
|
||||
: notif.level === 'warning'
|
||||
? 'bg-yellow-500/15 border-yellow-500/30'
|
||||
: 'bg-blue-500/15 border-blue-500/30'"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0" :class="notif.level === 'error' ? 'text-red-400' : notif.level === 'warning' ? 'text-yellow-400' : 'text-blue-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ notif.title }}</p>
|
||||
<p class="text-xs text-white/60 mt-0.5">{{ notif.message }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-white/40 hover:text-white/80 transition-colors shrink-0"
|
||||
@click="dismissNotification(notif.id)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
|
||||
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
const dismissedNotifications = ref<Set<string>>(new Set())
|
||||
|
||||
const healthNotifications = computed(() => {
|
||||
const notifs = store.data?.notifications ?? []
|
||||
const packages = store.data?.['package-data'] ?? {}
|
||||
const visible = notifs.filter((n) => {
|
||||
if (dismissedNotifications.value.has(n.id)) return false
|
||||
|
||||
const appId = n.app_id || appIdFromNotificationTitle(n.title)
|
||||
if (appId) {
|
||||
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
|
||||
const pkg = packages[appId]
|
||||
if (!pkg) return false
|
||||
if (pkg.health !== 'unhealthy') return false
|
||||
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
|
||||
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// Deduplicate: keep only the latest notification per container/title
|
||||
const seen = new Map<string, typeof visible[0]>()
|
||||
for (const n of visible) {
|
||||
seen.set(n.title, n)
|
||||
}
|
||||
return [...seen.values()].slice(-3)
|
||||
})
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
// Dismiss all notifications with the same title (container name)
|
||||
const notif = (store.data?.notifications ?? []).find(n => n.id === id)
|
||||
if (notif) {
|
||||
for (const n of store.data?.notifications ?? []) {
|
||||
if (n.title === notif.title) dismissedNotifications.value.add(n.id)
|
||||
}
|
||||
}
|
||||
dismissedNotifications.value.add(id)
|
||||
}
|
||||
|
||||
function appIdFromNotificationTitle(title: string): string | undefined {
|
||||
const suffix = ' is unhealthy'
|
||||
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
|
||||
}
|
||||
|
||||
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
|
||||
const ts = Date.parse(timestamp)
|
||||
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
|
||||
import HealthNotifications from '../HealthNotifications.vue'
|
||||
|
||||
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
|
||||
return {
|
||||
state,
|
||||
health,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
}
|
||||
}
|
||||
|
||||
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
|
||||
return {
|
||||
'server-info': {
|
||||
id: 'node',
|
||||
version: '1.0.0',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': {
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
updated: false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
'seed-backed': false,
|
||||
},
|
||||
'package-data': pkg ? { indeedhub: pkg } : {},
|
||||
notifications: [{
|
||||
id: 'health-1',
|
||||
level: 'error',
|
||||
title: 'indeedhub is unhealthy',
|
||||
message: 'indeedhub health check failed',
|
||||
timestamp,
|
||||
app_id: 'indeedhub',
|
||||
}],
|
||||
ui: {
|
||||
name: null,
|
||||
'ack-welcome': '',
|
||||
marketplace: {
|
||||
'selected-hosts': [],
|
||||
'known-hosts': {},
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('HealthNotifications', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows active unhealthy package notifications', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides stale package notifications once health recovers', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides package notifications while an app is being removed', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(
|
||||
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
|
||||
new Date('2026-06-10T11:20:00Z').toISOString(),
|
||||
)
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,982 @@
|
||||
/* Dashboard animations and transitions
|
||||
* Extracted from Dashboard.vue — 2advanced-style cinematic motion system
|
||||
*/
|
||||
|
||||
/* Background - zoom in from depth with motion blur */
|
||||
.zoom-reveal-bg {
|
||||
animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
transform-origin: center center;
|
||||
opacity: 0;
|
||||
transform: scale(0.15);
|
||||
filter: blur(24px);
|
||||
}
|
||||
|
||||
@keyframes zoom-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.15);
|
||||
filter: blur(24px);
|
||||
}
|
||||
35% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.5);
|
||||
filter: blur(20px);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.88);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 2advanced-style glass assembly - fluid, layered, deliberate timing */
|
||||
.glass-throw-active {
|
||||
perspective: 1400px;
|
||||
}
|
||||
|
||||
.glass-piece {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Sidebar - animates in at end with separate parts (like cards) */
|
||||
.sidebar-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid transparent;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-shell {
|
||||
animation: sidebar-shell-fly 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 5.2s;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@keyframes sidebar-shell-fly {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
|
||||
}
|
||||
|
||||
/* Only hide sidebar content when doing the login entrance animation */
|
||||
.sidebar-animate .sidebar-inner {
|
||||
opacity: 0;
|
||||
animation: sidebar-inner-draw 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 6.1s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-inner-draw {
|
||||
0% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
20% { opacity: 1; }
|
||||
100% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-nav-item {
|
||||
animation: sidebar-nav-item-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(6.3s + var(--nav-stagger, 0) * 0.06s);
|
||||
}
|
||||
|
||||
@keyframes sidebar-nav-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-controller {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-controller {
|
||||
animation: sidebar-fade-in 0.4s ease-out forwards;
|
||||
animation-delay: 6.9s;
|
||||
}
|
||||
|
||||
.sidebar-logout-btn {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-logout-btn {
|
||||
animation: sidebar-logout-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation-delay: 7.1s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sidebar-logout-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-logo {
|
||||
animation: sidebar-logo-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 6.15s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-logo-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* When not animating, show everything (direct load / hard refresh) */
|
||||
aside:not(.sidebar-animate) .sidebar-shell {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
aside:not(.sidebar-animate) .sidebar-inner,
|
||||
aside:not(.sidebar-animate) .sidebar-logo,
|
||||
aside:not(.sidebar-animate) .sidebar-nav-item,
|
||||
aside:not(.sidebar-animate) .sidebar-controller,
|
||||
aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
/* Glass throw animations — smooth easeInOut, no overshoot */
|
||||
|
||||
.glass-throw-main {
|
||||
animation: glass-throw-main 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.15s forwards;
|
||||
opacity: 0;
|
||||
transform: translateX(20%) scale(0.2);
|
||||
filter: blur(14px);
|
||||
}
|
||||
|
||||
.glass-throw-content {
|
||||
animation: glass-throw-content 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.22s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(12%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-mobile-tabs {
|
||||
animation: glass-throw-top 1.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.08s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-mobile-tabs-2 {
|
||||
animation: glass-throw-top 1.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.18s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-tabbar {
|
||||
animation: glass-throw-bottom 1.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(85%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
@keyframes glass-throw-sidebar {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) scale(0.25);
|
||||
filter: blur(12px);
|
||||
}
|
||||
45% {
|
||||
opacity: 0.9;
|
||||
transform: translateX(-15%) scale(0.85);
|
||||
filter: blur(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-main {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20%) scale(0.2);
|
||||
filter: blur(14px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: translateX(0) scale(0.9);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-content {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.9);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.95);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-bottom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(85%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.95);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Oomph accent - subtle flash synced with boot thud */
|
||||
.oomph-flash {
|
||||
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.08) 0%, transparent 65%);
|
||||
animation: oomph-flash 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes oomph-flash {
|
||||
0% { opacity: 0; }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Reveal flashes - enthralling entrance during zoom */
|
||||
.reveal-flash-glitch {
|
||||
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.12) 0%, transparent 70%);
|
||||
animation: reveal-flash-sequence 2.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes reveal-flash-sequence {
|
||||
0% { opacity: 0; }
|
||||
12% { opacity: 0.6; }
|
||||
18% { opacity: 0; }
|
||||
42% { opacity: 0.4; }
|
||||
48% { opacity: 0; }
|
||||
70% { opacity: 0.35; }
|
||||
78% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Panel mode app session */
|
||||
.app-panel-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.panel-slide-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.panel-slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
.panel-slide-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.panel-slide-leave-to {
|
||||
transform: translateX(40px) scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Perspective container for 3D depth effect */
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.perspective-container {
|
||||
perspective: 2000px;
|
||||
perspective-origin: 50% 50%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View wrapper — smooth transitions with absolute positioning */
|
||||
.view-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* preserve-3d + backface-visibility only during transitions (applied by
|
||||
transition classes below). Keeping them always-on causes Chromium to skip
|
||||
painting cards that start below the viewport — they appear as transparent
|
||||
ghost rectangles when scrolled into view. */
|
||||
will-change: transform, opacity;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
/* No forced height — content sizes naturally, spacer below provides clearance */
|
||||
}
|
||||
|
||||
/* Forward transition: 2advanced fluid depth */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-800px) scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.depth-forward-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(400px) scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back transition: 2advanced fluid depth */
|
||||
.depth-back-enter-active.view-wrapper,
|
||||
.depth-back-leave-active.view-wrapper {
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.depth-back-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(400px) scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.depth-back-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-back-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-back-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-800px) scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Subtle 3D tilt - 2advanced layered depth (desktop only) */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
|
||||
}
|
||||
|
||||
.depth-back-enter-from.view-wrapper {
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
|
||||
}
|
||||
|
||||
.depth-back-leave-to.view-wrapper {
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat open transition — chat slides in from left */
|
||||
.chat-open-enter-active.view-wrapper,
|
||||
.chat-open-leave-active.view-wrapper {
|
||||
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.chat-open-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px) scale(0.96);
|
||||
}
|
||||
|
||||
.chat-open-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.chat-open-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.chat-open-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.96);
|
||||
}
|
||||
|
||||
/* Chat close transition — chat slides out to left */
|
||||
.chat-close-enter-active.view-wrapper,
|
||||
.chat-close-leave-active.view-wrapper {
|
||||
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.chat-close-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.96);
|
||||
}
|
||||
|
||||
.chat-close-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.chat-close-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.chat-close-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px) scale(0.96);
|
||||
}
|
||||
|
||||
/* Fade transition for initial loads and default cases */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile: Slide left transition (Apps -> Marketplace) */
|
||||
.slide-left-enter-active.view-wrapper,
|
||||
.slide-left-leave-active.view-wrapper {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from.view-wrapper {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-enter-to.view-wrapper {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-left-leave-from.view-wrapper {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-left-leave-to.view-wrapper {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Mobile: Slide right transition (Marketplace -> Apps) */
|
||||
.slide-right-enter-active.view-wrapper,
|
||||
.slide-right-leave-active.view-wrapper {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-right-enter-from.view-wrapper {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-enter-to.view-wrapper {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-right-leave-from.view-wrapper {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-right-leave-to.view-wrapper {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Slide down: Moving down the menu (content slides up like a scroll) */
|
||||
.slide-down-enter-active.view-wrapper {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.slide-down-leave-active.view-wrapper {
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-down-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateY(40vh);
|
||||
}
|
||||
|
||||
.slide-down-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-down-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-down-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateY(-30vh);
|
||||
}
|
||||
|
||||
/* Slide up: Moving up the menu (content slides down like a scroll) */
|
||||
.slide-up-enter-active.view-wrapper {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.slide-up-leave-active.view-wrapper {
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateY(-40vh);
|
||||
}
|
||||
|
||||
.slide-up-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-up-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-up-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateY(30vh);
|
||||
}
|
||||
|
||||
/* Background 3D container - full width, black fill during zoom */
|
||||
.dashboard-view .bg-perspective-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
perspective: 1000px;
|
||||
perspective-origin: 50% 50%;
|
||||
overflow: hidden;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Background layers with 3D transitions */
|
||||
.dashboard-view .bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Default state - bg-intro visible, bg-intro-3 hidden back */
|
||||
.dashboard-view .bg-layer:first-of-type {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
}
|
||||
|
||||
.dashboard-view .bg-layer:nth-of-type(2) {
|
||||
opacity: 0;
|
||||
transform: translateZ(-200px) scale(0.9) rotateY(-15deg);
|
||||
}
|
||||
|
||||
/* Transitioning out - current background moves away with zoom */
|
||||
.dashboard-view .bg-layer.bg-transitioning-out {
|
||||
opacity: 0;
|
||||
transform: translateZ(200px) scale(1.15) rotateY(15deg) !important;
|
||||
}
|
||||
|
||||
/* Transitioning in - new background comes forward with zoom */
|
||||
.dashboard-view .bg-layer.bg-transitioning-in {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1.05) rotateY(0deg) !important;
|
||||
}
|
||||
|
||||
/* Kiosk: chromium runs software-composited (--in-process-gpu or
|
||||
--disable-gpu, single raster thread). 3D-transformed will-change layers
|
||||
routinely fail to repaint there after the first background swap, leaving
|
||||
the container's black fill on screen. Flatten the stack to plain 2D
|
||||
opacity crossfades in kiosk mode. */
|
||||
html.kiosk-mode .dashboard-view .bg-perspective-container {
|
||||
perspective: none;
|
||||
}
|
||||
html.kiosk-mode .dashboard-view .bg-layer {
|
||||
transform: none !important;
|
||||
transform-style: flat;
|
||||
will-change: auto;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
/* Kiosk: keep tab changes animated with cheap 2D moves. The 3D depth
|
||||
transitions (translateZ + blur + preserve-3d) drop frames / paint black
|
||||
under the software compositor, and the onboarding-era blanket
|
||||
`transform: none` on .view-wrapper killed tab motion entirely. Plain 2D
|
||||
scale + opacity composites fine there. */
|
||||
html.kiosk-mode .depth-forward-enter-active.view-wrapper,
|
||||
html.kiosk-mode .depth-forward-leave-active.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-active.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-active.view-wrapper {
|
||||
transition: transform 0.45s ease, opacity 0.45s ease;
|
||||
transform-style: flat;
|
||||
backface-visibility: visible;
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-enter-from.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-to.view-wrapper {
|
||||
transform: scale(0.94);
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-leave-to.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-from.view-wrapper {
|
||||
transform: scale(1.05);
|
||||
filter: none !important;
|
||||
}
|
||||
html.kiosk-mode .depth-forward-enter-to.view-wrapper,
|
||||
html.kiosk-mode .depth-forward-leave-from.view-wrapper,
|
||||
html.kiosk-mode .depth-back-enter-to.view-wrapper,
|
||||
html.kiosk-mode .depth-back-leave-from.view-wrapper {
|
||||
transform: scale(1);
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Background glitch effect layers - World Fair style */
|
||||
.bg-glitch-layer-1,
|
||||
.bg-glitch-layer-2,
|
||||
.bg-glitch-scan {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.bg-glitch-layer-1 {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
mix-blend-mode: lighten;
|
||||
filter: brightness(1.8) contrast(2) saturate(1.5) hue-rotate(180deg);
|
||||
will-change: transform, clip-path, opacity;
|
||||
}
|
||||
|
||||
.bg-glitch-layer-2 {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
mix-blend-mode: color-dodge;
|
||||
filter: brightness(2) contrast(2) saturate(2) hue-rotate(90deg);
|
||||
will-change: transform, clip-path, opacity;
|
||||
}
|
||||
|
||||
.bg-glitch-scan {
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
rgba(255,0,255,0.2) 0%,
|
||||
rgba(0,255,255,0.2) 25%,
|
||||
rgba(255,255,0,0.2) 50%,
|
||||
rgba(0,255,255,0.2) 75%,
|
||||
rgba(255,0,255,0.2) 100%
|
||||
),
|
||||
repeating-linear-gradient(0deg,
|
||||
rgba(255,255,255,0.05) 0px,
|
||||
rgba(255,255,255,0.05) 2px,
|
||||
transparent 2px,
|
||||
transparent 4px
|
||||
);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Trigger glitch animation when active */
|
||||
.bg-glitch-layer-1.glitch-active {
|
||||
animation: bg-glitch-shift 0.375s steps(15, end) forwards;
|
||||
}
|
||||
|
||||
.bg-glitch-layer-2.glitch-active {
|
||||
animation: bg-glitch-shift-2 0.375s steps(12, end) forwards;
|
||||
}
|
||||
|
||||
.bg-glitch-scan.glitch-active {
|
||||
animation: bg-glitch-scan 0.375s linear forwards;
|
||||
}
|
||||
|
||||
/* World Fair style - visible but tasteful glitch */
|
||||
@keyframes bg-glitch-shift {
|
||||
0% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
5% { opacity: 0.5; }
|
||||
12% { transform: translate(15px,-8px); clip-path: inset(12% 0 70% 0); }
|
||||
20% { transform: translate(-20px,10px); clip-path: inset(45% 0 35% 0); }
|
||||
28% { transform: translate(18px,-5px); clip-path: inset(68% 0 15% 0); }
|
||||
36% { transform: translate(-15px,12px); clip-path: inset(20% 0 60% 0); }
|
||||
44% { transform: translate(22px,-10px); clip-path: inset(52% 0 28% 0); }
|
||||
52% { transform: translate(-18px,8px); clip-path: inset(10% 0 75% 0); }
|
||||
60% { transform: translate(12px,-6px); clip-path: inset(58% 0 22% 0); }
|
||||
68% { transform: translate(-10px,15px); clip-path: inset(32% 0 48% 0); }
|
||||
76% { transform: translate(16px,-4px); clip-path: inset(72% 0 12% 0); }
|
||||
84% { transform: translate(-12px,7px); clip-path: inset(18% 0 65% 0); }
|
||||
92% { transform: translate(8px,-3px); clip-path: inset(42% 0 40% 0); }
|
||||
96% { opacity: 0.4; }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-shift-2 {
|
||||
0% { transform: translate(0,0) skewX(0deg); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
8% { opacity: 0.5; }
|
||||
15% { transform: translate(-18px,10px) skewX(4deg); clip-path: inset(25% 0 55% 0); }
|
||||
23% { transform: translate(22px,-12px) skewX(-5deg); clip-path: inset(50% 0 30% 0); }
|
||||
31% { transform: translate(-16px,8px) skewX(3deg); clip-path: inset(72% 0 12% 0); }
|
||||
39% { transform: translate(20px,-15px) skewX(-4deg); clip-path: inset(18% 0 65% 0); }
|
||||
47% { transform: translate(-22px,12px) skewX(5deg); clip-path: inset(42% 0 38% 0); }
|
||||
55% { transform: translate(18px,-8px) skewX(-3deg); clip-path: inset(62% 0 20% 0); }
|
||||
63% { transform: translate(-14px,14px) skewX(4deg); clip-path: inset(30% 0 52% 0); }
|
||||
71% { transform: translate(16px,-6px) skewX(-2deg); clip-path: inset(8% 0 78% 0); }
|
||||
79% { transform: translate(-12px,10px) skewX(3deg); clip-path: inset(55% 0 28% 0); }
|
||||
87% { transform: translate(10px,-4px) skewX(-2deg); clip-path: inset(35% 0 45% 0); }
|
||||
95% { opacity: 0.4; }
|
||||
100% { transform: translate(0,0) skewX(0deg); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-scan {
|
||||
0% { opacity: 0; transform: translateX(-120%); }
|
||||
5% { opacity: 0.5; }
|
||||
15% { opacity: 0.55; transform: translateX(-80%); }
|
||||
30% { opacity: 0.6; transform: translateX(-40%); }
|
||||
50% { opacity: 0.6; transform: translateX(0%); }
|
||||
70% { opacity: 0.55; transform: translateX(40%); }
|
||||
85% { opacity: 0.5; transform: translateX(80%); }
|
||||
95% { opacity: 0.45; }
|
||||
100% { opacity: 0; transform: translateX(120%); }
|
||||
}
|
||||
|
||||
/* Full width background */
|
||||
.dashboard-view .bg-fullwidth {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
/* Continuous glitch overlays - every 5s */
|
||||
.dashboard-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dashboard-glitch-1 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(22deg) saturate(1.35);
|
||||
animation: dashboard-glitch-shift 5s steps(10, end) infinite;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
.dashboard-glitch-2 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(-30deg) saturate(1.45);
|
||||
animation: dashboard-glitch-shift-2 5s steps(9, end) infinite;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
.dashboard-glitch-scan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0) 60%),
|
||||
repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 2px, rgba(0,0,0,0) 2px 4px),
|
||||
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.35) 100%);
|
||||
opacity: 0;
|
||||
animation: dashboard-glitch-scan 5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Pause dashboard glitch animations during tab switch (backdrop-filter fix) */
|
||||
html.tab-hidden .dashboard-glitch-1,
|
||||
html.tab-hidden .dashboard-glitch-2,
|
||||
html.tab-hidden .dashboard-glitch-scan {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
@keyframes dashboard-glitch-shift {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.28; }
|
||||
84% { transform: translate(6px,-2px); clip-path: inset(8% 0 70% 0); }
|
||||
86% { transform: translate(-5px,2px); clip-path: inset(42% 0 40% 0); }
|
||||
88% { transform: translate(3px,0); clip-path: inset(68% 0 10% 0); }
|
||||
91% { transform: translate(-4px,3px); clip-path: inset(18% 0 60% 0); }
|
||||
93% { transform: translate(5px,-3px); clip-path: inset(55% 0 20% 0); }
|
||||
95% { transform: translate(-3px,1px); clip-path: inset(10% 0 80% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes dashboard-glitch-shift-2 {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.24; }
|
||||
84% { transform: translate(-6px,2px); clip-path: inset(12% 0 65% 0); }
|
||||
86% { transform: translate(5px,-1px) skewX(0.6deg); clip-path: inset(36% 0 42% 0); }
|
||||
89% { transform: translate(-3px,2px); clip-path: inset(72% 0 8% 0); }
|
||||
92% { transform: translate(4px,-3px); clip-path: inset(22% 0 58% 0); }
|
||||
95% { transform: translate(-4px,1px); clip-path: inset(50% 0 26% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes dashboard-glitch-scan {
|
||||
0%, 82% { opacity: 0; transform: translateY(-20%); }
|
||||
84% { opacity: 0.5; }
|
||||
90% { opacity: 0.35; }
|
||||
100% { opacity: 0; transform: translateY(115%); }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
/** Tab order for vertical transitions between main navigation items */
|
||||
const TAB_ORDER = [
|
||||
'/dashboard',
|
||||
'/dashboard/apps',
|
||||
'/dashboard/marketplace',
|
||||
'/dashboard/cloud',
|
||||
'/dashboard/mesh',
|
||||
'/dashboard/server',
|
||||
'/dashboard/web5',
|
||||
'/dashboard/fleet',
|
||||
'/dashboard/chat',
|
||||
'/dashboard/settings'
|
||||
]
|
||||
|
||||
/** Web5 group sub-tab order for mobile horizontal swipe transitions */
|
||||
const WEB5_TAB_ORDER = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/server', '/dashboard/mesh']
|
||||
|
||||
/** Route-to-background image mapping */
|
||||
export const ROUTE_BACKGROUNDS: Record<string, string> = {
|
||||
'/dashboard': 'bg-home.webp',
|
||||
'/dashboard/': 'bg-home.webp',
|
||||
'/dashboard/apps': 'bg-myapps.webp',
|
||||
'/dashboard/discover': 'bg-appstore.webp',
|
||||
'/dashboard/marketplace': 'bg-appstore.webp',
|
||||
'/dashboard/cloud': 'bg-cloud.webp',
|
||||
'/dashboard/mesh': 'bg-mesh.webp',
|
||||
'/dashboard/server': 'bg-network.jpg',
|
||||
'/dashboard/web5': 'bg-web5.jpg',
|
||||
'/dashboard/server/federation': 'bg-web5.jpg',
|
||||
'/dashboard/monitoring': 'bg-web5.jpg',
|
||||
'/dashboard/fleet': 'bg-web5.jpg',
|
||||
'/dashboard/settings': 'bg-settings.webp',
|
||||
'/dashboard/chat': 'bg-aiui.jpg',
|
||||
}
|
||||
|
||||
export function isDetailRoute(path: string): boolean {
|
||||
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
|
||||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a route transition tracker that determines the appropriate
|
||||
* CSS transition name based on navigation direction and route depth.
|
||||
*/
|
||||
export function useRouteTransitions() {
|
||||
let previousPath = ''
|
||||
let previousTab = ''
|
||||
|
||||
function getTransitionName(currentRoute: RouteLocationNormalizedLoaded): string {
|
||||
const currentPath = currentRoute.path
|
||||
|
||||
if (!previousPath) {
|
||||
previousPath = currentPath
|
||||
return 'fade'
|
||||
}
|
||||
|
||||
// Chat transitions: directional slide
|
||||
const isChat = currentPath === '/dashboard/chat'
|
||||
const wasChat = previousPath === '/dashboard/chat'
|
||||
if (isChat) {
|
||||
previousPath = currentPath
|
||||
return 'chat-open'
|
||||
}
|
||||
if (wasChat) {
|
||||
previousPath = currentPath
|
||||
return 'chat-close'
|
||||
}
|
||||
|
||||
const isAppDetails = currentPath.includes('/apps/') && !currentPath.endsWith('/apps')
|
||||
const isAppsList = currentPath === '/dashboard/apps'
|
||||
const wasAppDetails = previousPath.includes('/apps/') && !previousPath.endsWith('/apps')
|
||||
const wasAppsList = previousPath === '/dashboard/apps'
|
||||
|
||||
const isMarketplaceDetails = currentPath.includes('/marketplace/') && !currentPath.endsWith('/marketplace')
|
||||
const isMarketplaceList = currentPath === '/dashboard/marketplace'
|
||||
const wasMarketplaceDetails = previousPath.includes('/marketplace/') && !previousPath.endsWith('/marketplace')
|
||||
const wasMarketplaceList = previousPath === '/dashboard/marketplace'
|
||||
|
||||
const isCloudFolder = currentPath.includes('/cloud/') && !currentPath.endsWith('/cloud')
|
||||
const isCloudList = currentPath === '/dashboard/cloud'
|
||||
const wasCloudFolder = previousPath.includes('/cloud/') && !previousPath.endsWith('/cloud')
|
||||
const wasCloudList = previousPath === '/dashboard/cloud'
|
||||
|
||||
const isFederation = currentPath === '/dashboard/server/federation'
|
||||
const wasFederation = previousPath === '/dashboard/server/federation'
|
||||
const isMonitoring = currentPath === '/dashboard/monitoring'
|
||||
const wasMonitoring = previousPath === '/dashboard/monitoring'
|
||||
const isFleet = currentPath === '/dashboard/fleet'
|
||||
const wasFleet = previousPath === '/dashboard/fleet'
|
||||
const isWeb5 = currentPath === '/dashboard/web5'
|
||||
const wasWeb5 = previousPath === '/dashboard/web5'
|
||||
// Any Web5 sub-detail (networking-profits, credentials, …) animates as a
|
||||
// depth push from/back-to the Web5 tab — same feel as Find Nodes.
|
||||
const isWeb5Detail = currentPath.startsWith('/dashboard/web5/')
|
||||
const wasWeb5Detail = previousPath.startsWith('/dashboard/web5/')
|
||||
|
||||
let transitionName = 'fade'
|
||||
|
||||
// Mobile: Horizontal slide transitions between sub-tabs
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
const isServices = currentPath === '/dashboard/apps' && (currentRoute.query.tab === 'services' || currentRoute.query.tab === 'websites')
|
||||
const wasServices = previousTab === 'services' || previousTab === 'websites'
|
||||
const currentAppsIdx = isServices ? 2
|
||||
: currentPath === '/dashboard/marketplace' ? 1
|
||||
: currentPath === '/dashboard/apps' ? 0 : -1
|
||||
const prevAppsIdx = wasServices ? 2
|
||||
: previousPath === '/dashboard/marketplace' ? 1
|
||||
: previousPath === '/dashboard/apps' ? 0 : -1
|
||||
|
||||
const currentWeb5Idx = WEB5_TAB_ORDER.indexOf(currentPath)
|
||||
const prevWeb5Idx = WEB5_TAB_ORDER.indexOf(previousPath)
|
||||
|
||||
if (currentAppsIdx !== -1 && prevAppsIdx !== -1 && currentAppsIdx !== prevAppsIdx) {
|
||||
transitionName = currentAppsIdx > prevAppsIdx ? 'slide-left' : 'slide-right'
|
||||
} else if (currentWeb5Idx !== -1 && prevWeb5Idx !== -1 && currentWeb5Idx !== prevWeb5Idx) {
|
||||
transitionName = currentWeb5Idx > prevWeb5Idx ? 'slide-left' : 'slide-right'
|
||||
} else {
|
||||
const currentIndex = TAB_ORDER.indexOf(currentPath)
|
||||
const previousIndex = TAB_ORDER.indexOf(previousPath)
|
||||
|
||||
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
|
||||
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Desktop depth transitions: list <-> detail
|
||||
else if (wasAppsList && isAppDetails) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasAppDetails && isAppsList) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasMarketplaceList && isMarketplaceDetails) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasMarketplaceDetails && isMarketplaceList) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasCloudList && isCloudFolder) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasCloudFolder && isCloudList) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasWeb5 && isFederation) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasFederation && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasWeb5 && isMonitoring) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasMonitoring && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasWeb5 && isFleet) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasFleet && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasWeb5 && isWeb5Detail) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasWeb5Detail && isWeb5) {
|
||||
transitionName = 'depth-back'
|
||||
} else if (wasMarketplaceList && isAppDetails) {
|
||||
transitionName = 'depth-forward'
|
||||
} else if (wasAppDetails && isMarketplaceList) {
|
||||
transitionName = 'depth-back'
|
||||
}
|
||||
// Desktop: no transition between Apps <-> Marketplace (same-page tab feel)
|
||||
else if ((wasAppsList && isMarketplaceList) || (wasMarketplaceList && isAppsList)) {
|
||||
transitionName = 'fade'
|
||||
}
|
||||
// Vertical transition: between main tabs (desktop)
|
||||
else {
|
||||
const currentIndex = TAB_ORDER.indexOf(currentPath)
|
||||
const previousIndex = TAB_ORDER.indexOf(previousPath)
|
||||
|
||||
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
|
||||
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
|
||||
}
|
||||
}
|
||||
|
||||
previousPath = currentPath
|
||||
previousTab = (currentRoute.query.tab as string) || ''
|
||||
|
||||
return transitionName
|
||||
}
|
||||
|
||||
return { getTransitionName }
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
|
||||
<template v-if="isLoading && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-5 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-5 transition-all hover:-translate-y-1 cursor-pointer flex flex-col"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index + staggerOffset }"
|
||||
@click="$emit('view-details', app)"
|
||||
@keydown.enter="$emit('view-details', app)"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-14 h-14 rounded-lg object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h3 class="text-lg font-semibold text-white truncate">{{ app.title }}</h3>
|
||||
<span
|
||||
v-if="getAppTier(app.id) !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/50">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<p v-if="app.author" class="text-xs text-white/40 mt-0.5">{{ app.author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trust badge for Nostr apps -->
|
||||
<div v-if="app.trustTier" class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-green-400/20 text-green-400': app.trustTier === 'verified',
|
||||
'bg-yellow-400/20 text-yellow-400': app.trustTier === 'community',
|
||||
'bg-orange-400/20 text-orange-400': app.trustTier === 'unverified',
|
||||
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
|
||||
}"
|
||||
>{{ app.trustTier }}</span>
|
||||
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/70 text-sm mb-4 line-clamp-3 flex-1">
|
||||
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<!-- Installed & starting up -->
|
||||
<span
|
||||
v-if="isInstalled(app.id) && isStartingUp(app.id)"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ getInstalledState(app.id) === 'installing' ? 'Installing...' : 'Starting...' }}
|
||||
</span>
|
||||
<!-- Installed & ready -->
|
||||
<span
|
||||
v-else-if="isInstalled(app.id)"
|
||||
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
|
||||
>Installed</span>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>Launch</button>
|
||||
<!-- Scanning (skipped in demo — there are no real containers to scan) -->
|
||||
<span
|
||||
v-else-if="!IS_DEMO && !containersScanned && (app.source === 'local' || app.dockerImage)"
|
||||
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
|
||||
>
|
||||
<span class="discover-shimmer-bg"></span>
|
||||
<span class="relative flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Demo: app not demoable -->
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>Not available in demo</button>
|
||||
<!-- Install button -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@click.stop="$emit('install', app)"
|
||||
:disabled="installingApps.has(app.id)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installingApps.get(app.id)?.message || 'Installing...' }}
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<!-- Not available -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>Not Available</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
|
||||
<div v-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">No community apps found</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">No apps found{{ searchQuery ? ` for "${searchQuery}"` : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
showStagger: boolean
|
||||
staggerOffset: number
|
||||
containersScanned: boolean
|
||||
installingApps: Map<string, { message: string }>
|
||||
isInstalled: (id: string) => boolean
|
||||
isStartingUp: (id: string) => boolean
|
||||
getInstalledState: (id: string) => string | null
|
||||
getAppTier: (id: string) => string
|
||||
isLoading: boolean
|
||||
loadingMessage: string
|
||||
nostrError: string
|
||||
isNostrCategory: boolean
|
||||
searchQuery: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'view-details': [app: MarketplaceApp]
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
'retry-nostr': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-shimmer-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 245px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="btc-face-wrap">
|
||||
<pre class="btc-face-canvas" v-html="frameHtml"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const frameHtml = ref('')
|
||||
|
||||
const W = 74
|
||||
const H = 38
|
||||
const CX = W / 2
|
||||
const CY = H / 2
|
||||
const RX = 30
|
||||
const RY = 16
|
||||
|
||||
// Bitcoin symbol bitmap (12 wide, 11 tall)
|
||||
const SYM = [
|
||||
' # # ',
|
||||
' ######## ',
|
||||
' ### ## ',
|
||||
' ### ### ',
|
||||
' ### ## ',
|
||||
' ########## ',
|
||||
' ### ## ',
|
||||
' ### ### ',
|
||||
' ### ## ',
|
||||
' ######## ',
|
||||
' # # ',
|
||||
]
|
||||
const SYM_W = SYM[0]!.length
|
||||
const SYM_OX = -Math.floor(SYM_W / 2)
|
||||
const SYM_OY = -Math.floor(SYM.length / 2) + 1
|
||||
|
||||
function isInSym(fx: number, fy: number): boolean {
|
||||
const bx = Math.round(fx) - SYM_OX
|
||||
const by = Math.round(fy) - SYM_OY
|
||||
if (by < 0 || by >= SYM.length || bx < 0 || bx >= SYM_W) return false
|
||||
return SYM[by]![bx] === '#'
|
||||
}
|
||||
|
||||
// Eyes
|
||||
const EYE_Y = -7, EYE_H = 3, EYE_L = -9, EYE_R = 8
|
||||
|
||||
function isEye(fx: number, fy: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
if (rx >= EYE_L - 2 && rx <= EYE_L + 2 && ry >= EYE_Y && ry <= EYE_Y + EYE_H - 1) return true
|
||||
if (rx >= EYE_R - 2 && rx <= EYE_R + 2 && ry >= EYE_Y && ry <= EYE_Y + EYE_H - 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isPupil(fx: number, fy: number, lookX: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
const p = Math.round(lookX * 0.8)
|
||||
if (rx >= EYE_L + p && rx <= EYE_L + p + 1 && ry >= EYE_Y && ry <= EYE_Y + 1) return true
|
||||
if (rx >= EYE_R + p && rx <= EYE_R + p + 1 && ry >= EYE_Y && ry <= EYE_Y + 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Mouth
|
||||
function isMouth(fx: number, fy: number): boolean {
|
||||
const ry = Math.round(fy), rx = Math.round(fx)
|
||||
if (ry === 9 && rx >= -5 && rx <= 5) return Math.abs(rx) >= 2
|
||||
if (ry === 8 && (rx === -5 || rx === 5)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function mouthChar(fx: number): string {
|
||||
const rx = Math.round(fx)
|
||||
return (rx === -5 || rx === 5) ? "'" : '~'
|
||||
}
|
||||
|
||||
const EDGE = '@#%*+=:. '
|
||||
|
||||
function render(f: number): string {
|
||||
const bob = Math.sin(f * 0.055) * 1.2
|
||||
const breathe = Math.sin(f * 0.028) * 0.4
|
||||
const lookX = Math.sin(f * 0.02)
|
||||
const blink = (f % 160 >= 152 && f % 160 <= 157) || (f % 480 >= 300 && f % 480 <= 305)
|
||||
|
||||
const lines: string[] = []
|
||||
for (let y = 0; y < H; y++) {
|
||||
let line = ''
|
||||
for (let x = 0; x < W; x++) {
|
||||
const fy = y - CY + bob, fx = x - CX
|
||||
const rx = RX + breathe * 2, ry = RY + breathe
|
||||
const dx = fx / rx, dy = fy / ry
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (dist > 1.15) {
|
||||
line += ' '
|
||||
} else if (dist > 1.0) {
|
||||
const sh = Math.sin(f * 0.08 + Math.atan2(dy, dx) * 3) * 0.5 + 0.5
|
||||
const ci = Math.floor(((dist - 1.0) / 0.15) * 6 + sh * 2)
|
||||
const c = EDGE[Math.min(ci, EDGE.length - 1)]
|
||||
line += c === ' ' ? ' ' : `<span class="af-g">${c}</span>`
|
||||
} else if (dist > 0.9) {
|
||||
const sh = Math.sin(f * 0.1 + Math.atan2(dy, dx) * 4) * 0.5 + 0.5
|
||||
let ci = Math.floor((1 - (dist - 0.9) / 0.1) * 3 + sh * 2)
|
||||
ci = Math.max(0, Math.min(ci, 4))
|
||||
line += `<span class="af-b">${'@#%*+'[ci]}</span>`
|
||||
} else if (dist > 0.85) {
|
||||
const sh = Math.sin(f * 0.1 + Math.atan2(dy, dx) * 4 + 1) * 0.5 + 0.5
|
||||
line += `<span class="af-b">${'#%'[Math.floor(sh * 2)]}</span>`
|
||||
} else if (isEye(fx, fy)) {
|
||||
if (blink) line += '<span class="af-b">-</span>'
|
||||
else if (isPupil(fx, fy, lookX)) line += '<span class="af-p">@</span>'
|
||||
else line += ' '
|
||||
} else if (isInSym(fx, fy)) {
|
||||
const gl = Math.sin(f * 0.06 + fx * 0.3) * 0.3
|
||||
line += `<span class="${gl > 0.1 ? 'af-m' : 'af-b'}">$</span>`
|
||||
} else if (isMouth(fx, fy)) {
|
||||
line += `<span class="af-b">${mouthChar(fx)}</span>`
|
||||
} else {
|
||||
const w = Math.sin(f * 0.04 + x * 0.12 + y * 0.08)
|
||||
const d = (dist / 0.85) * 2 + w * 0.6
|
||||
line += d < 1.2 ? '=' : d < 1.6 ? '+' : d < 2.0 ? ':' : d < 2.3 ? '-' : '.'
|
||||
}
|
||||
}
|
||||
lines.push(line)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Animation loop — 24fps (lighter than 30 for embedded use)
|
||||
let frame = 0, lastTime = -1, running = false, rafId: number | null = null
|
||||
const FT = 1000 / 24
|
||||
|
||||
function tick(ts: number) {
|
||||
if (lastTime === -1) lastTime = ts
|
||||
let dt = ts - lastTime
|
||||
let dirty = false
|
||||
while (dt >= FT) { frame++; dt -= FT; lastTime += FT; dirty = true }
|
||||
if (dirty) frameHtml.value = render(frame)
|
||||
if (running) rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!running) { running = true; lastTime = -1; rafId = requestAnimationFrame(tick) }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
|
||||
}
|
||||
|
||||
function onVisChange() {
|
||||
if (document.hidden) stop(); else start()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
frameHtml.value = render(0)
|
||||
} else {
|
||||
document.addEventListener('visibilitychange', onVisChange)
|
||||
start()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btc-face-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btc-face-canvas {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 7px;
|
||||
line-height: 1.15;
|
||||
white-space: pre;
|
||||
color: #6a6a6e;
|
||||
letter-spacing: 0.3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btc-face-canvas :deep(.af-b) { color: #F7931A; }
|
||||
.btc-face-canvas :deep(.af-g) { color: #c98a20; }
|
||||
.btc-face-canvas :deep(.af-p) { color: #ffffff; }
|
||||
.btc-face-canvas :deep(.af-m) { color: #e8a43a; }
|
||||
.btc-face-canvas :deep(.af-d) { color: #52524e; }
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!-- Companion app banner — same format as the featured app banner, with a
|
||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||
Install button) opens the Remote Companion download/pairing modal. -->
|
||||
<div
|
||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="openCompanionIntro()"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/companion-banner-bg.webp"
|
||||
alt=""
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => ((e.target as HTMLImageElement).style.display = 'none')"
|
||||
/>
|
||||
<div class="featured-banner-overlay companion-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">companion</span>
|
||||
<span class="text-white/50 text-sm font-mono">REMOTE CONTROL // IN YOUR POCKET</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">Your Node. In Your Pocket.</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">
|
||||
The Archipelago Companion puts your node on your phone — remote control and typing,
|
||||
your cloud files on the go, and one scan to connect. Sovereignty doesn't stay home.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click.stop="openCompanionIntro()"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Install</button>
|
||||
<span class="text-white/40 text-sm">Archipelago Companion · Android</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="companion-phone-wrap" aria-hidden="true">
|
||||
<div class="companion-phone">
|
||||
<span class="companion-phone-lens"></span>
|
||||
<img src="/assets/img/companion-phone-screen.webp" alt="" class="companion-phone-screen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.companion-banner {
|
||||
border-color: rgba(251, 146, 60, 0.25);
|
||||
}
|
||||
|
||||
/* Keep the copy clear of the phone mockup on wide screens */
|
||||
@media (min-width: 768px) {
|
||||
.companion-banner-overlay {
|
||||
padding-right: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone-wrap {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.companion-phone-wrap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 3.5rem;
|
||||
z-index: 2;
|
||||
transform: rotate(6deg);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.companion-banner:hover .companion-phone-wrap {
|
||||
transform: rotate(4deg) translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.companion-phone {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
padding: 9px;
|
||||
border-radius: 30px;
|
||||
background: #0b0b12;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.55),
|
||||
0 0 46px rgba(251, 146, 60, 0.28),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Punch-hole camera */
|
||||
.companion-phone-lens {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.companion-phone-screen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* Screen glare */
|
||||
.companion-phone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 28%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden">
|
||||
<div class="discover-hero-scanline" aria-hidden="true"></div>
|
||||
<div class="discover-hero-layout relative z-10">
|
||||
<div class="discover-hero-content">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-hero-face">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Principles Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-base font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinFaceAscii from './BitcoinFaceAscii.vue'
|
||||
|
||||
defineProps<{
|
||||
totalApps: number
|
||||
installedCount: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<h2 class="text-xl font-bold text-white">Sovereignty Stack</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div
|
||||
v-for="(app, index) in featuredApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('view-details', app)"
|
||||
@keydown.enter="$emit('view-details', app)"
|
||||
>
|
||||
<div class="flex items-start gap-5">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-xl font-bold text-white truncate">{{ app.title }}</h3>
|
||||
<span
|
||||
v-if="getAppTier(app.id) !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ getAppTier(app.id) }}</span>
|
||||
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
|
||||
</div>
|
||||
<p class="text-white/50 text-sm mb-3">{{ app.author }} · {{ $ver(app.version) }}</p>
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ app.featuredDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-white/8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-white/60 text-sm font-mono">{{ app.privacyTag }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>Launch</button>
|
||||
<span
|
||||
v-else-if="isInstalled(app.id) && isStartingUp(app.id)"
|
||||
class="text-yellow-200 text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Starting...
|
||||
</span>
|
||||
<button
|
||||
v-else-if="!IS_DEMO && !containersScanned && app.dockerImage"
|
||||
disabled
|
||||
class="text-white/40 text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Checking...
|
||||
</button>
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
|
||||
>Not available in demo</button>
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||
data-controller-install-btn
|
||||
@click.stop="$emit('install', app)"
|
||||
:disabled="installingApps.has(app.id)"
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="installingApps.has(app.id)" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Installing...
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
showStagger: boolean
|
||||
containersScanned: boolean
|
||||
installingApps: Map<string, { message: string }>
|
||||
isInstalled: (id: string) => boolean
|
||||
isStartingUp: (id: string) => boolean
|
||||
getAppTier: (id: string) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'view-details': [app: MarketplaceApp]
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Floating Filter Button (Mobile) -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showFilter = true"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filter Modal (Mobile) -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeFilter"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
||||
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-6 h-6" 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 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="$emit('select-category', category.id); closeFilter()"
|
||||
:class="[
|
||||
'p-4 rounded-xl font-medium transition-all text-left',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white border-2 border-white/40'
|
||||
: 'glass-card text-white/80 hover:bg-orange-500/5 hover:border-orange-500/15'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">{{ category.name }}</p>
|
||||
<p v-if="selectedCategory === category.id" class="text-xs text-white/60 mt-1">Currently viewing</p>
|
||||
</div>
|
||||
<svg v-if="selectedCategory === category.id" class="w-5 h-5 text-white flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import type { CategoryDef } from './types'
|
||||
|
||||
defineProps<{
|
||||
categories: CategoryDef[]
|
||||
selectedCategory: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'select-category': [id: string]
|
||||
}>()
|
||||
|
||||
const showFilter = ref(false)
|
||||
const filterModalRef = ref<HTMLElement | null>(null)
|
||||
const filterRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeFilter() {
|
||||
filterRestoreFocusRef.value?.focus?.()
|
||||
showFilter.value = false
|
||||
}
|
||||
|
||||
useModalKeyboard(filterModalRef, showFilter, closeFilter, { restoreFocusRef: filterRestoreFocusRef })
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
|
||||
const R = '146.59.87.168:3000/lfg2025'
|
||||
|
||||
// ---------- Dynamic catalog from registry ----------
|
||||
export interface CatalogFeatured {
|
||||
id: string
|
||||
banner: string
|
||||
headline: string
|
||||
description: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface AppCatalog {
|
||||
version: number
|
||||
registry: string
|
||||
featured: CatalogFeatured
|
||||
apps: MarketplaceApp[]
|
||||
}
|
||||
|
||||
let cachedCatalog: AppCatalog | null = null
|
||||
let catalogFetchedAt = 0
|
||||
const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
||||
|
||||
/** Catalog URLs tried in order. First success wins.
|
||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||
* to the static copy baked into the frontend build. */
|
||||
const CATALOG_URLS = [
|
||||
'/api/app-catalog',
|
||||
'/catalog.json',
|
||||
]
|
||||
|
||||
/** Fetch app catalog from remote registry, with local fallback.
|
||||
* Caches for 1 hour. Returns null only if ALL sources fail. */
|
||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
// Return cache if fresh
|
||||
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
|
||||
|
||||
for (const url of CATALOG_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) })
|
||||
if (!res.ok) continue
|
||||
const data = await res.json() as AppCatalog
|
||||
if (!data.apps?.length) continue
|
||||
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
catalogFetchedAt = Date.now()
|
||||
// Cache in localStorage for offline fallback
|
||||
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
|
||||
return data
|
||||
} catch (e) {
|
||||
console.warn(`[catalog] fetch failed for ${url}:`, e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try localStorage cache as final fallback
|
||||
try {
|
||||
const stored = localStorage.getItem('archy_catalog')
|
||||
if (stored) {
|
||||
cachedCatalog = JSON.parse(stored) as AppCatalog
|
||||
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
|
||||
return cachedCatalog
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[catalog] localStorage fallback unreadable:', e)
|
||||
}
|
||||
|
||||
console.warn('[catalog] all sources failed — using hardcoded app list')
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------- Hardcoded fallback (used when catalog.json is unavailable) ----------
|
||||
export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.png', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
|
||||
{ id: 'immich', title: 'Immich', version: '1.90.0', description: 'High-performance photo and video backup. Mobile-first with ML features.', icon: '/assets/img/app-icons/immich.png', author: 'Immich', dockerImage: `${R}/immich-server:release`, repoUrl: 'https://github.com/immich-app/immich' },
|
||||
{ id: 'filebrowser', title: 'File Browser', version: '2.27.0', description: 'Web-based file manager. Browse, upload, and manage files on your server.', icon: '/assets/img/app-icons/file-browser.webp', author: 'File Browser', dockerImage: `${R}/filebrowser:v2.27.0`, repoUrl: 'https://github.com/filebrowser/filebrowser' },
|
||||
{ id: 'nginx-proxy-manager', title: 'Nginx Proxy Manager', version: '2.12.1', description: 'Reverse proxy with SSL. Beautiful web interface for managing proxies.', icon: '/assets/img/app-icons/nginx.svg', author: 'Nginx Proxy Manager', dockerImage: `${R}/nginx-proxy-manager:latest`, repoUrl: 'https://github.com/NginxProxyManager/nginx-proxy-manager' },
|
||||
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
|
||||
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
|
||||
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
|
||||
{ id: 'netbird', title: 'NetBird', version: '0.71.2', description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.', icon: '/assets/img/app-icons/netbird.svg', author: 'NetBird', dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0', repoUrl: 'https://github.com/netbirdio/netbird' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint Guardian', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'fedimint-clientd', title: 'Fedimint Client', version: '0.8.0', description: 'Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fmcd:0.8.1`, repoUrl: 'https://github.com/minmoto/fmcd' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
|
||||
]
|
||||
}
|
||||
|
||||
// Only PRIMARY containers trigger "installed" status.
|
||||
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
'fedimint-clientd': ['fedimint-clientd'],
|
||||
electrumx: ['electrumx'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
vaultwarden: ['vaultwarden'],
|
||||
searxng: ['searxng'],
|
||||
homeassistant: ['homeassistant'],
|
||||
photoprism: ['photoprism'],
|
||||
lnd: ['lnd'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
netbird: ['netbird'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
botfights: ['botfights'],
|
||||
}
|
||||
|
||||
// Featured apps shown at the top of the App Store.
|
||||
// The first entry with a `banner` is displayed as a full-width hero banner.
|
||||
// To change the featured app, move the desired entry to position 0 and set its `banner`.
|
||||
export const FEATURED_DEFINITIONS: {
|
||||
id: string
|
||||
desc: string
|
||||
tag: string
|
||||
banner?: string // path to banner image (shown as full-width hero)
|
||||
}[] = [
|
||||
{
|
||||
id: 'indeedhub',
|
||||
desc: 'Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node. No accounts, no subscriptions. Sign in with Nostr.',
|
||||
tag: 'NOSTR IDENTITY // YOUR NODE',
|
||||
banner: '/assets/img/featured/indeedhub-banner.jpg',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
desc: 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.',
|
||||
tag: 'FULL VALIDATION // ZERO TRUST',
|
||||
},
|
||||
{
|
||||
id: 'bitcoin-core',
|
||||
desc: 'The reference Bitcoin implementation. Same full-node guarantees as Knots, tracking upstream releases from the Bitcoin Core maintainers. Pick this if you\'d rather run mainline Bitcoin Core than Knots — both validate every block themselves.',
|
||||
tag: 'REFERENCE CLIENT // ZERO TRUST',
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
desc: 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.',
|
||||
tag: 'INSTANT SETTLEMENT // YOUR CHANNELS',
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
desc: 'Accept Bitcoin payments without intermediaries. No fees to payment processors. No KYC. No permission needed. Your commerce, your terms. Self-hosted payment infrastructure that makes you truly independent.',
|
||||
tag: 'NO INTERMEDIARIES // NO KYC',
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
desc: 'Your passwords belong to you. Self-hosted password vault with full Bitwarden compatibility. Zero-knowledge encryption means even you can\'t see your passwords without your master key. No cloud required — your secrets, your server.',
|
||||
tag: 'ZERO KNOWLEDGE // SELF-HOSTED',
|
||||
},
|
||||
]
|
||||
|
||||
export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
if (app.category) return app.category
|
||||
const id = app.id.toLowerCase()
|
||||
const title = app.title?.toLowerCase() || ''
|
||||
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') || id.includes('proxy') || id.includes('dns') || id.includes('tor') || combined.includes('network')) return 'networking'
|
||||
if (id.includes('matrix') || id.includes('mastodon') || id.includes('chat') || id.includes('social') || combined.includes('messaging')) return 'community'
|
||||
return 'other'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
|
||||
|
||||
/** Container config that can be passed from the remote catalog to the backend
|
||||
* for apps not hardcoded in config.rs */
|
||||
export interface ContainerConfig {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
|
||||
id: string
|
||||
trustScore?: number
|
||||
trustTier?: string
|
||||
relayCount?: number
|
||||
containerConfig?: ContainerConfig
|
||||
requires?: string[]
|
||||
tier?: string
|
||||
}
|
||||
|
||||
export type FeaturedApp = MarketplaceApp & {
|
||||
featuredDescription: string
|
||||
privacyTag: string
|
||||
bannerImage?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
id: string
|
||||
title: string
|
||||
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
attempt: number
|
||||
}
|
||||
|
||||
export interface CategoryDef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
|
||||
<p class="text-xs text-white/60 mt-1">
|
||||
Browses Nostr presence events from configured relays. Sending a
|
||||
peer request never reveals your onion — only your DID + npub +
|
||||
an optional message travel inside an encrypted DM.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="text-white/40 hover:text-white/70 transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ loading ? 'Searching…' : 'Search Relays' }}
|
||||
</button>
|
||||
<span v-if="lastSearchAt" class="text-[11px] text-white/40">
|
||||
Last search: {{ lastSearchAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div>
|
||||
|
||||
<!-- Manual entry: paste an npub directly -->
|
||||
<div class="mb-6 p-3 bg-white/5 rounded-lg border border-white/10">
|
||||
<p class="text-xs text-white/60 mb-2">
|
||||
Already know an npub? Send a peer request directly.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
v-model="manualNpub"
|
||||
placeholder="npub1…"
|
||||
class="flex-1 bg-black/30 text-white text-xs rounded px-3 py-2 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="!manualNpub.trim() || sendingTo === manualNpub.trim()"
|
||||
@click="sendDirect()"
|
||||
>
|
||||
Send Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="nodes.length === 0 && !loading" class="text-center py-8 text-white/40 text-sm">
|
||||
No discoverable nodes found. Either no peers are advertising on the
|
||||
configured relays, or your discoverability hasn't been enabled long
|
||||
enough for relays to gossip yours.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching relays...
|
||||
</div>
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.nostr_pubkey"
|
||||
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white truncate">
|
||||
{{ shortNpub(node.nostr_npub) }}
|
||||
</div>
|
||||
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
|
||||
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
|
||||
:disabled="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)"
|
||||
@click="sendTo(node)"
|
||||
>
|
||||
{{ statusFor(node) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<PeerRequestModal
|
||||
:show="requestTarget !== null"
|
||||
:target-label="requestTarget?.label ?? ''"
|
||||
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
|
||||
@send="confirmRequest"
|
||||
@cancel="requestTarget = null"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
|
||||
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
|
||||
|
||||
interface DiscoverableNode {
|
||||
nostr_pubkey: string
|
||||
nostr_npub: string
|
||||
did: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/// Outbound rows from the parent's pending list, used to grey out
|
||||
/// "Send Request" buttons for npubs we've already requested.
|
||||
outboundSent: PendingPeerRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
/// Fired after a successful send so the parent can refresh its
|
||||
/// pending-requests list to show the new "Sent" row.
|
||||
sent: []
|
||||
}>()
|
||||
|
||||
const nodes = ref<DiscoverableNode[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastSearchAt = ref('')
|
||||
const sendingTo = ref<string | null>(null)
|
||||
const manualNpub = ref('')
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (v && nodes.value.length === 0) refresh()
|
||||
},
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await rpcClient.handshakeDiscover()
|
||||
nodes.value = result.nodes
|
||||
lastSearchAt.value = new Date().toLocaleTimeString()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Request confirmation modal: peer requests always offer an optional
|
||||
// message before anything is sent (Request/Cancel).
|
||||
const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null)
|
||||
|
||||
function sendTo(node: DiscoverableNode) {
|
||||
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
|
||||
}
|
||||
|
||||
function sendDirect() {
|
||||
const v = manualNpub.value.trim()
|
||||
if (!v) return
|
||||
requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}…${v.slice(-6)}` : v, clearManual: true }
|
||||
}
|
||||
|
||||
async function confirmRequest(message: string | undefined) {
|
||||
const req = requestTarget.value
|
||||
if (!req) return
|
||||
await sendInternal(req.target, message)
|
||||
if (req.clearManual) manualNpub.value = ''
|
||||
requestTarget.value = null
|
||||
}
|
||||
|
||||
async function sendInternal(target: string, message?: string) {
|
||||
sendingTo.value = target
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.handshakeConnect(target, message)
|
||||
emit('sent')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Send failed'
|
||||
} finally {
|
||||
sendingTo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function alreadySentTo(npubHex: string): boolean {
|
||||
return props.outboundSent.some(
|
||||
(r) => r.outbound && r.from_nostr_pubkey === npubHex && r.state === 'sent',
|
||||
)
|
||||
}
|
||||
|
||||
function statusFor(node: DiscoverableNode): string {
|
||||
if (sendingTo.value === node.nostr_pubkey) return 'Sending…'
|
||||
if (alreadySentTo(node.nostr_pubkey)) return 'Already sent'
|
||||
return 'Send Request'
|
||||
}
|
||||
|
||||
function shortNpub(npub: string): string {
|
||||
if (!npub || npub.length < 16) return npub
|
||||
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||
}
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
|
||||
<p class="text-white/70">Connect, sync, and share with trusted nodes</p>
|
||||
</div>
|
||||
<!-- Your Node DID — top right card (desktop) -->
|
||||
<div v-if="selfDid" class="hidden md:block shrink-0">
|
||||
<div class="glass-card px-4 py-3 flex items-center gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: DID below title -->
|
||||
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
|
||||
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
|
||||
</div>
|
||||
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { shortDid } from './utils'
|
||||
import { safeClipboardWrite } from '../web5/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
selfDid: string
|
||||
serverName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
rotate: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const didCopied = ref(false)
|
||||
|
||||
const shortDidDisplay = computed(() => shortDid(props.selfDid))
|
||||
|
||||
function handleCopy() {
|
||||
if (props.selfDid) {
|
||||
safeClipboardWrite(props.selfDid)
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user