Podman emits zero parseable progress when stderr is piped (no TTY), so the old byte-counter regex never matched in real installs. Users saw 0% for the whole pull, then a jump to 95%, then silence through create-container, health-check, and post-install hooks. Replace with 7 explicit lifecycle phases wired through install.rs and update.rs: Preparing (5%), PullingImage (20%), CreatingContainer (70%), StartingContainer (80%), WaitingHealthy (88%), PostInstall (95%), Done (100%). Each maps to a fixed UI progress and status message. Frontend PHASE_INFO mapper in stores/server.ts prioritizes phase when present, falls back to byte-counter for legacy. A Math.max forward-only guard ensures the bar never regresses. Deleted the duplicate watcher in Discover.vue that was fighting the store's watcher with stale byte logic. Added shimmer CSS on the fill (with prefers-reduced-motion opt-out) so the bar looks alive during long phases.
536 lines
21 KiB
Vue
536 lines
21 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 + 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 }} v{{ featuredBannerApp?.version }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<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 } 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 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, 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 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.
|
|
// 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
|
|
const maxAttempts = ref(60)
|
|
|
|
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)
|
|
)
|
|
}
|
|
// 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', '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)
|
|
}
|
|
|
|
const toast = useToast()
|
|
|
|
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 })
|
|
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
|
|
router.push('/dashboard/apps').catch(() => {})
|
|
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 }, timeout: 15000 })
|
|
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 })
|
|
toast.info(`Installing ${app.title ?? app.id} — check My Apps`)
|
|
router.push('/dashboard/apps').catch(() => {})
|
|
try {
|
|
installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
|
|
// Pass containerConfig from catalog if available (allows dynamic apps without hardcoded backend config)
|
|
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: 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: 15000 })
|
|
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()
|
|
}
|
|
})
|
|
|
|
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>
|
|
|