Move installingApps from local refs in Marketplace/Discover to the global server store. Install progress now persists when navigating between views. My Apps shows installing overlay with progress bar for apps being installed from the Marketplace. Changes: - server.ts: add installingApps Map + helpers to store - Marketplace.vue: use store's installingApps instead of local ref - Discover.vue: same - Apps.vue: pass isInstalling + installProgress to AppCard - AppCard.vue: add amber installing overlay with progress bar 522 tests pass, vue-tsc clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
478 lines
18 KiB
Vue
478 lines
18 KiB
Vue
<template>
|
|
<div class="discover-container">
|
|
<!-- Navigation Bar (always at top) -->
|
|
<div>
|
|
<!-- Desktop: tabs + categories + search -->
|
|
<div class="hidden md:flex mb-6 items-center gap-4">
|
|
<div class="mode-switcher flex-shrink-0">
|
|
<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="mode-switcher flex-shrink-0">
|
|
<RouterLink
|
|
to="/dashboard/discover"
|
|
class="mode-switcher-btn"
|
|
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
|
>Discover</RouterLink>
|
|
<button
|
|
v-for="category in categoriesWithApps"
|
|
:key="category.id"
|
|
@click="navigateToMarketplace(category.id)"
|
|
class="mode-switcher-btn"
|
|
>
|
|
{{ category.name }}
|
|
</button>
|
|
</div>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search apps..."
|
|
aria-label="Search apps"
|
|
class="flex-1 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: search -->
|
|
<div class="md:hidden mb-4">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search apps..."
|
|
aria-label="Search apps"
|
|
class="w-full px-4 py-3 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>
|
|
|
|
<!-- Hero + Featured (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"
|
|
/>
|
|
|
|
<!-- 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">All Applications</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/60 text-sm 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/30 text-xs mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
|
</div>
|
|
|
|
<FilterModal
|
|
:categories="categoriesWithApps"
|
|
:selected-category="selectedCategory"
|
|
@select-category="selectCategory"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
let discoverAnimationDone = false
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } 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 DiscoverHero from './discover/DiscoverHero.vue'
|
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
|
import AppGrid from './discover/AppGrid.vue'
|
|
import FilterModal from './discover/FilterModal.vue'
|
|
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
|
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } 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 categories = computed(() => [
|
|
{ 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: 'l484', name: 'L484' },
|
|
{ id: 'other', name: 'Other' }
|
|
])
|
|
|
|
// Installation state — uses global store so it persists across navigation
|
|
const installingApps = serverStore.installingApps
|
|
const maxAttempts = ref(60)
|
|
|
|
watch(() => store.packages, (packages) => {
|
|
if (!packages) return
|
|
for (const [appId, pkg] of Object.entries(packages)) {
|
|
const progress = pkg['install-progress']
|
|
if (progress && pkg.state === 'installing' && installingApps.has(appId)) {
|
|
const current = installingApps.get(appId)!
|
|
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
|
const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1)
|
|
const totalMB = (progress.size / (1024 * 1024)).toFixed(1)
|
|
installingApps.set(appId, {
|
|
...current,
|
|
status: 'downloading',
|
|
progress: Math.min(pct, 95),
|
|
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
|
|
})
|
|
}
|
|
}
|
|
}, { deep: true })
|
|
|
|
function selectCategory(id: string) {
|
|
selectedCategory.value = id
|
|
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
|
loadNostrMarketplace()
|
|
}
|
|
}
|
|
|
|
function navigateToMarketplace(categoryId: string) {
|
|
router.push({ name: 'marketplace', query: { category: 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 containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
|
|
|
|
|
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 categoriesWithApps = computed(() => {
|
|
const apps = allApps.value
|
|
return categories.value.filter(cat => {
|
|
if (cat.id === 'all') return apps.length > 0
|
|
return apps.some(app => app.category === cat.id)
|
|
})
|
|
})
|
|
|
|
const filteredApps = computed(() => {
|
|
let apps = allApps.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)
|
|
)
|
|
}
|
|
apps.sort((a, b) => {
|
|
const aInstalled = isInstalled(a.id) ? 1 : 0
|
|
const bInstalled = isInstalled(b.id) ? 1 : 0
|
|
return aInstalled - bInstalled
|
|
})
|
|
return apps
|
|
})
|
|
|
|
const installedCount = computed(() => {
|
|
return allApps.value.filter(app => isInstalled(app.id)).length
|
|
})
|
|
|
|
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', 'dwn', 'filebrowser']
|
|
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
|
|
if (core.includes(appId)) return 'core'
|
|
if (recommended.includes(appId)) return 'recommended'
|
|
return 'optional'
|
|
}
|
|
|
|
function launchInstalledApp(app: MarketplaceApp) {
|
|
appLauncher.openSession(app.id)
|
|
}
|
|
|
|
function handleInstall(app: MarketplaceApp) {
|
|
if (app.source === 'local') {
|
|
installApp(app)
|
|
} else {
|
|
installCommunityApp(app)
|
|
}
|
|
}
|
|
|
|
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>[] = []
|
|
const activeIntervals: ReturnType<typeof setInterval>[] = []
|
|
|
|
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
|
|
}
|
|
|
|
function trackInterval(fn: () => void, ms: number) {
|
|
const id = setInterval(fn, ms)
|
|
activeIntervals.push(id)
|
|
return id
|
|
}
|
|
|
|
function clearTrackedInterval(id: ReturnType<typeof setInterval>) {
|
|
clearInterval(id)
|
|
const idx = activeIntervals.indexOf(id)
|
|
if (idx !== -1) activeIntervals.splice(idx, 1)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
for (const t of activeTimers) clearTimeout(t)
|
|
activeTimers.length = 0
|
|
for (const i of activeIntervals) clearInterval(i)
|
|
activeIntervals.length = 0
|
|
})
|
|
|
|
function startInstallPolling(appId: string, statusMessage: string) {
|
|
const interval = trackInterval(() => {
|
|
const current = installingApps.get(appId)
|
|
if (!current) { clearTrackedInterval(interval); return }
|
|
const newAttempt = current.attempt + 1
|
|
installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage })
|
|
if (isInstalled(appId)) {
|
|
clearTrackedInterval(interval)
|
|
installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
|
|
trackTimeout(() => { installingApps.delete(appId) }, 2000)
|
|
} else if (newAttempt >= maxAttempts.value) {
|
|
clearTrackedInterval(interval)
|
|
installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
|
|
trackTimeout(() => { installingApps.delete(appId) }, 5000)
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
async function installApp(app: MarketplaceApp) {
|
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 })
|
|
try {
|
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
|
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
|
|
startInstallPolling(app.id, 'Starting application...')
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
|
}
|
|
}
|
|
|
|
async function installCommunityApp(app: MarketplaceApp) {
|
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
|
installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 })
|
|
try {
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 })
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
|
|
startInstallPolling(app.id, 'Initializing application...')
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err)
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
|
|
trackTimeout(() => { installingApps.delete(app.id) }, 5000)
|
|
}
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
discoverAnimationDone = true
|
|
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
|
loadCommunityMarketplace()
|
|
}
|
|
})
|
|
|
|
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
|
|
}
|
|
</script>
|
|
|