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:
co-authored by
Claude Opus 4.6
parent
224681f1e0
commit
46747607ea
@@ -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">· {{ 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user