feat: add ARIA labels, roles, and live regions across all views (A11Y-01)

Systematic accessibility pass: aria-label on icon-only buttons, role=dialog
and aria-modal on modals, role=tab/tablist on tab switchers, role=switch
on toggles, aria-live on dynamic status/error regions, aria-hidden on
decorative SVGs, aria-label on search inputs, and nav landmarks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 13:04:31 +00:00
co-authored by Claude Opus 4.6
parent 224681f1e0
commit 46747607ea
8 changed files with 557 additions and 176 deletions
+210 -72
View File
@@ -3,7 +3,7 @@
<!-- Fixed Header Section -->
<div class="flex-shrink-0">
<!-- Installation Progress Banner - Multiple Apps -->
<div v-if="installingApps.size > 0" class="mb-6 space-y-3">
<div v-if="installingApps.size > 0" aria-live="polite" class="mb-6 space-y-3">
<div
v-for="[appId, progress] in installingApps"
:key="appId"
@@ -17,11 +17,12 @@
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg
<svg
v-if="progress.status !== 'complete' && progress.status !== 'error'"
class="animate-spin h-5 w-5 text-blue-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="animate-spin h-5 w-5 text-blue-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>
@@ -81,6 +82,37 @@
</div>
<!-- Source Tabs: Curated / Community -->
<div class="flex mb-4 gap-2" role="tablist">
<button
@click="marketplaceSource = 'curated'"
role="tab"
:aria-selected="marketplaceSource === 'curated'"
:class="[
'px-5 py-2 rounded-lg text-sm font-medium transition-all',
marketplaceSource === 'curated'
? 'bg-white/20 text-white border border-white/20'
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Curated
</button>
<button
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
role="tab"
:aria-selected="marketplaceSource === 'community'"
:class="[
'px-5 py-2 rounded-lg text-sm font-medium transition-all',
marketplaceSource === 'community'
? 'bg-white/20 text-white border border-white/20'
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Community
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
</button>
</div>
<!-- Category Tabs + Search (Desktop only) -->
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
<div class="flex flex-wrap gap-2">
@@ -102,23 +134,40 @@
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
class="flex-shrink-0 w-64 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>
<!-- Search Bar (Mobile) -->
<div class="md:hidden mb-4">
<!-- Mobile: Category + Search -->
<div class="md:hidden mb-4 space-y-3">
<div class="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
:class="[
'whitespace-nowrap px-4 py-2 rounded-lg text-sm font-medium transition-all shrink-0',
selectedCategory === category.id
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white/80 bg-white/5'
]"
>
{{ category.name }}
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
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="flex-1 overflow-y-auto pr-2 -mr-2 pb-4">
<div class="flex-1 overflow-y-auto pr-2 -mr-2 pb-24">
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
@@ -150,7 +199,22 @@
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
</div>
</div>
<!-- Trust badge for Nostr community apps -->
<div v-if="app.trustTier" class="flex items-center gap-2 mb-3">
<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>
<span v-if="app.relayCount" class="text-xs text-white/40">&middot; {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
</div>
<p class="text-white/80 text-sm mb-4 line-clamp-3 flex-1">
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
</p>
@@ -192,12 +256,17 @@
<!-- Empty State -->
<div v-if="filteredApps.length === 0" class="text-center py-12">
<div v-if="loadingCommunity" class="flex flex-col items-center gap-4">
<div v-if="loadingCommunity || nostrLoading" class="flex flex-col items-center gap-4">
<svg class="animate-spin h-12 w-12 text-blue-400" 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">Loading community apps...</p>
<p class="text-white/70">{{ marketplaceSource === 'community' ? 'Querying Nostr relays for apps...' : 'Loading apps...' }}</p>
</div>
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
<p class="text-white/70">No community apps discovered yet.</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">Retry</button>
</div>
<p v-else class="text-white/70">No apps found in {{ categories.find(c => c.id === selectedCategory)?.name }}{{ searchQuery ? ` matching "${searchQuery}"` : '' }}</p>
</div>
@@ -299,13 +368,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: number; trustTier?: string; relayCount?: number }
const router = useRouter()
const store = useAppStore()
const { setCurrentApp } = useMarketplaceApp()
@@ -338,6 +409,26 @@ interface InstallProgress {
const installingApps = ref<Map<string, InstallProgress>>(new Map())
const maxAttempts = ref(60)
// Watch WebSocket data for real install progress from backend
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.value.has(appId)) {
const current = installingApps.value.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.value.set(appId, {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
})
}
}
}, { deep: true })
// Filter modal state (for mobile)
const showFilterModal = ref(false)
const filterModalRef = ref<HTMLElement | null>(null)
@@ -348,12 +439,51 @@ function closeFilterModal() {
}
useModalKeyboard(filterModalRef, showFilterModal, closeFilterModal, { restoreFocusRef: filterRestoreFocusRef })
// Source tab: curated (built-in Docker apps) vs community (Nostr relay discovery)
const marketplaceSource = ref<'curated' | 'community'>('curated')
// Community marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
const communityApps = ref<any[]>([])
const communityApps = ref<MarketplaceApp[]>([])
const searchQuery = ref('')
// Nostr community marketplace state
const nostrApps = ref<(MarketplaceApp & { trustScore?: number; trustTier?: string; relayCount?: number })[]>([])
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
}
}
// Available apps in marketplace
// const availableApps = ref([
// {
@@ -387,10 +517,10 @@ const installedPackages = computed(() => {
})
// Function to categorize community apps based on their ID and description
function categorizeCommunityApp(app: any): string {
function categorizeCommunityApp(app: MarketplaceApp): string {
const id = app.id.toLowerCase()
const title = app.title?.toLowerCase() || ''
const description = app.description?.toLowerCase() || ''
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
const combined = `${id} ${title} ${description}`
// Money category
@@ -447,19 +577,27 @@ function categorizeCommunityApp(app: any): string {
// Combine local and community apps with categories
const allApps = computed(() => {
// Local apps disabled until s9pk support is implemented
const local: any[] = []
if (marketplaceSource.value === 'community') {
// Show Nostr-discovered apps
return nostrApps.value.map(app => {
const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' }
})
}
// Curated: built-in Docker apps
const local: (MarketplaceApp & { category: string; source: string })[] = []
// Categorize community apps intelligently
const community = communityApps.value.map(app => {
const category = categorizeCommunityApp(app)
return {
...app,
...app,
category,
source: 'community'
}
})
return [...local, ...community]
})
@@ -486,7 +624,7 @@ const filteredApps = computed(() => {
const query = searchQuery.value.toLowerCase()
apps = apps.filter(app =>
app.title?.toLowerCase().includes(query) ||
app.description?.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)
@@ -531,7 +669,7 @@ async function loadCommunityMarketplace() {
// Use curated list of Docker-based apps
// These are standard Docker images, not StartOS packages
console.log('📦 Loading Docker-based app marketplace')
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
communityApps.value = getCuratedAppList()
loadingCommunity.value = false
}
@@ -546,19 +684,19 @@ function getCuratedAppList() {
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: 'docker.io/bitcoinknots/bitcoin:latest',
manifestUrl: null,
dockerImage: 'docker.io/bitcoinknots/bitcoin:v28.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/bitcoinknots/bitcoin'
},
{
id: 'electrs',
title: 'Electrs',
version: 'latest',
version: '0.4.1',
description: 'Electrum protocol indexer for Bitcoin. Powers Mempool and other Electrum clients. Requires Bitcoin Knots or Bitcoin Core.',
icon: '/assets/img/app-icons/electrs.svg',
author: 'Roman Zeyde',
dockerImage: 'docker.io/mempool/electrs:latest',
manifestUrl: null,
dockerImage: 'docker.io/mempool/electrs:v0.4.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/romanz/electrs'
},
{
@@ -569,7 +707,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/btcpay-server.png',
author: 'BTCPay Server Foundation',
dockerImage: 'docker.io/btcpayserver/btcpayserver:1.13.5',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
},
{
@@ -580,7 +718,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/lnd.svg',
author: 'Lightning Labs',
dockerImage: 'docker.io/lightninglabs/lnd:v0.17.4-beta',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/lightningnetwork/lnd'
},
{
@@ -591,7 +729,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/mempool.webp',
author: 'Mempool',
dockerImage: 'docker.io/mempool/frontend:v2.5.0',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/mempool/mempool'
},
{
@@ -602,7 +740,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/homeassistant.png',
author: 'Home Assistant',
dockerImage: 'docker.io/homeassistant/home-assistant:2024.1',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/home-assistant/core'
},
{
@@ -613,7 +751,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/grafana.png',
author: 'Grafana Labs',
dockerImage: 'docker.io/grafana/grafana:10.2.0',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/grafana/grafana'
},
{
@@ -623,8 +761,8 @@ function getCuratedAppList() {
description: 'Privacy-respecting metasearch engine. Search without tracking or ads.',
icon: '/assets/img/app-icons/searxng.png',
author: 'SearXNG',
dockerImage: 'docker.io/searxng/searxng:latest',
manifestUrl: null,
dockerImage: 'docker.io/searxng/searxng:2024.11.17-e2554de75',
manifestUrl: undefined,
repoUrl: 'https://github.com/searxng/searxng'
},
{
@@ -634,8 +772,8 @@ function getCuratedAppList() {
description: 'Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware.',
icon: '/assets/img/app-icons/ollama.png',
author: 'Ollama',
dockerImage: 'docker.io/ollama/ollama:latest',
manifestUrl: null,
dockerImage: 'docker.io/ollama/ollama:0.5.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/ollama/ollama'
},
{
@@ -646,18 +784,18 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/onlyoffice.webp',
author: 'Ascensio System SIA',
dockerImage: 'docker.io/onlyoffice/documentserver:7.5.1',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/ONLYOFFICE/DocumentServer'
},
{
id: 'penpot',
title: 'Penpot',
version: '2.0.0',
version: '2.4',
description: 'Open-source design and prototyping platform. Self-hosted alternative to Figma.',
icon: '/assets/img/app-icons/penpot.webp',
author: 'Penpot',
dockerImage: 'docker.io/penpotapp/frontend:latest',
manifestUrl: null,
dockerImage: 'docker.io/penpotapp/frontend:2.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/penpot/penpot'
},
{
@@ -668,7 +806,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/nextcloud.webp',
author: 'Nextcloud',
dockerImage: 'docker.io/library/nextcloud:28',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/nextcloud/server'
},
{
@@ -679,7 +817,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/vaultwarden.webp',
author: 'Vaultwarden',
dockerImage: 'docker.io/vaultwarden/server:1.30.0-alpine',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/dani-garcia/vaultwarden'
},
{
@@ -690,18 +828,18 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/jellyfin.webp',
author: 'Jellyfin',
dockerImage: 'docker.io/jellyfin/jellyfin:10.8.13',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/jellyfin/jellyfin'
},
{
id: 'photoprism',
title: 'PhotoPrism',
version: '231128',
version: '240915',
description: 'AI-powered photo management. Organize and browse photos with facial recognition.',
icon: '/assets/img/app-icons/photoprims.svg',
author: 'PhotoPrism',
dockerImage: 'docker.io/photoprism/photoprism:latest',
manifestUrl: null,
dockerImage: 'docker.io/photoprism/photoprism:240915',
manifestUrl: undefined,
repoUrl: 'https://github.com/photoprism/photoprism'
},
{
@@ -712,7 +850,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/immich.png',
author: 'Immich',
dockerImage: 'ghcr.io/immich-app/immich-server:release',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/immich-app/immich'
},
{
@@ -723,7 +861,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/file-browser.webp',
author: 'File Browser',
dockerImage: 'docker.io/filebrowser/filebrowser:v2.27.0',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/filebrowser/filebrowser'
},
{
@@ -733,8 +871,8 @@ function getCuratedAppList() {
description: 'Easy proxy management with SSL. Beautiful web interface for managing reverse proxies.',
icon: '/assets/img/app-icons/nginx.svg',
author: 'Nginx Proxy Manager',
dockerImage: 'docker.io/jc21/nginx-proxy-manager:latest',
manifestUrl: null,
dockerImage: 'docker.io/jc21/nginx-proxy-manager:2.12.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/NginxProxyManager/nginx-proxy-manager'
},
{
@@ -745,7 +883,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/portainer.webp',
author: 'Portainer',
dockerImage: 'docker.io/portainer/portainer-ce:2.19.4',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/portainer/portainer'
},
{
@@ -756,7 +894,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/uptime-kuma.webp',
author: 'Uptime Kuma',
dockerImage: 'docker.io/louislam/uptime-kuma:1',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/louislam/uptime-kuma'
},
{
@@ -767,7 +905,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/tailscale.webp',
author: 'Tailscale',
dockerImage: 'docker.io/tailscale/tailscale:stable',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/tailscale/tailscale'
},
{
@@ -778,7 +916,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/fedimint.png',
author: 'Fedimint',
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/fedimint/fedimint'
},
{
@@ -789,7 +927,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/indeedhub.png',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
{
@@ -800,7 +938,7 @@ function getCuratedAppList() {
icon: '/assets/img/app-icons/dwn.svg',
author: 'TBD',
dockerImage: 'ghcr.io/tbd54566975/dwn-server:main',
manifestUrl: null,
manifestUrl: undefined,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
@@ -810,20 +948,20 @@ function getCuratedAppList() {
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:latest',
manifestUrl: null,
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:0.9.0',
manifestUrl: undefined,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
}
]
}
function viewAppDetails(app: any) {
console.log('[Marketplace] Navigating to app detail:', app)
function viewAppDetails(app: MarketplaceApp) {
if (import.meta.env.DEV) console.log('[Marketplace] Navigating to app detail:', app)
try {
// If app is already installed, go directly to the installed app detail page
if (isInstalled(app.id)) {
console.log('[Marketplace] App is installed, navigating to app details page')
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 }
@@ -831,7 +969,7 @@ function viewAppDetails(app: any) {
} else {
// Store app data in composable for marketplace detail view
setCurrentApp(app)
console.log('[Marketplace] App data stored in composable')
if (import.meta.env.DEV) console.log('[Marketplace] App data stored in composable')
// Navigate to marketplace detail page
router.push({
@@ -840,7 +978,7 @@ function viewAppDetails(app: any) {
})
}
} catch (e) {
console.error('[Marketplace] Navigation error:', e)
if (import.meta.env.DEV) console.error('[Marketplace] Navigation error:', e)
}
}
@@ -901,13 +1039,13 @@ function startInstallPolling(appId: string, statusMessage: string) {
}, 1000)
}
async function installApp(app: any) {
async function installApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
installingApps.value.set(app.id, {
id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
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
@@ -919,17 +1057,17 @@ async function installApp(app: any) {
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
console.error('Installation failed:', err)
if (import.meta.env.DEV) console.error('Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
}
}
async function installCommunityApp(app: any) {
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
installingApps.value.set(app.id, {
id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
try {
@@ -945,7 +1083,7 @@ async function installCommunityApp(app: any) {
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
console.error('[Marketplace] Installation failed:', err)
if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
}