Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
:data-controller-install="!(installed || installing || installBlockedReason) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 hover:bg-orange-500/5 hover:border-orange-500/15 transition-all cursor-pointer flex flex-col"
|
||||
:class="{ 'card-stagger': stagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="$emit('view', app)"
|
||||
@keydown.enter="$emit('view', app)"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
:src="app.icon || DEFAULT_APP_ICON"
|
||||
:alt="app.title"
|
||||
class="w-16 h-16 rounded-lg archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-white mb-1">
|
||||
{{ app.title }}
|
||||
<span
|
||||
v-if="!installed && tierLabel !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tierLabel }}</span>
|
||||
</h3>
|
||||
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
|
||||
<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>
|
||||
|
||||
<p v-if="!installed && installBlockedReason" class="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-100">
|
||||
Requires a full archive Bitcoin node before install.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<!-- Installed & starting up (transitional state) -->
|
||||
<span
|
||||
v-if="installed && startingUp"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installedState === 'installing' ? 'Installing...' : 'Starting...' }}
|
||||
</span>
|
||||
<!-- Installed & ready -->
|
||||
<span
|
||||
v-else-if="installed"
|
||||
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
|
||||
>
|
||||
{{ t('marketplace.alreadyInstalled') }}
|
||||
</span>
|
||||
<button
|
||||
v-if="installed && !startingUp"
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<!-- Not yet scanned — show loading indicator instead of install -->
|
||||
<span
|
||||
v-else-if="!containersScanned && (app.source === 'local' || app.dockerImage)"
|
||||
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
|
||||
>
|
||||
<span class="marketplace-shimmer-bg"></span>
|
||||
<span class="relative flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Installing — live progress with bar + message matching the
|
||||
update download bar's accuracy. Falls back to a simple
|
||||
spinner if no install_progress data is available yet. -->
|
||||
<div
|
||||
v-else-if="!installed && installing"
|
||||
class="flex-1 flex flex-col gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium opacity-90 cursor-wait flex items-center justify-center gap-2 text-center"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="truncate">{{ installProgressMessage }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="(installProgress?.progress ?? 0) > 0"
|
||||
class="w-full h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-orange-400 transition-all duration-300"
|
||||
:style="{ width: (installProgress?.progress ?? 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!installed && installBlockedReason"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-100 text-sm font-medium"
|
||||
:title="installBlockedReason"
|
||||
@click.stop="$emit('install', app)"
|
||||
>
|
||||
Bitcoin Pruned
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@click.stop="$emit('install', app)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.install') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!installed"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>
|
||||
{{ t('common.notAvailable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
app: MarketplaceApp
|
||||
index: number
|
||||
stagger: boolean
|
||||
installed: boolean
|
||||
installing: boolean
|
||||
installProgress: InstallProgress | undefined
|
||||
installedState: string | null
|
||||
startingUp: boolean
|
||||
containersScanned: boolean
|
||||
tierLabel: string
|
||||
installBlockedReason?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
view: [app: MarketplaceApp]
|
||||
install: [app: MarketplaceApp]
|
||||
launch: [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
const installProgressMessage = computed(() => {
|
||||
const p = props.installProgress
|
||||
if (!p) return 'Installing'
|
||||
// The store already formats messages like "Downloading: 50.5 / 200.0 MB (25%)"
|
||||
// so we just surface them directly.
|
||||
return p.message || 'Installing'
|
||||
})
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
// Catalog icon extensions drift (.svg vs .png on disk) — try the sibling
|
||||
// extension once before giving up on the placeholder
|
||||
if (!img.dataset.extRetry && /\.(svg|png)$/.test(img.src)) {
|
||||
img.dataset.extRetry = '1'
|
||||
img.src = img.src.endsWith('.svg')
|
||||
? img.src.replace(/\.svg$/, '.png')
|
||||
: img.src.replace(/\.png$/, '.svg')
|
||||
return
|
||||
}
|
||||
if (!img.src.includes(DEFAULT_APP_ICON)) {
|
||||
img.src = DEFAULT_APP_ICON
|
||||
img.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.marketplace-shimmer-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<!-- Floating Filter Button (teleported to escape CSS transform containing block) -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showModal = true"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filter Modal (Mobile only) -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||
@click.self="close()"
|
||||
>
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||
<button
|
||||
@click="close()"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="$emit('select', category.id); close()"
|
||||
:class="[
|
||||
'p-4 rounded-xl font-medium transition-all text-left',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white border-2 border-white/40'
|
||||
: 'glass-card text-white/80 hover:bg-white/10'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Category Icon -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg v-if="category.id === 'all'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'community'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'nostr'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'commerce'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'money'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'data'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'home'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'car'" class="w-6 h-6" viewBox="0 0 122.88 122.88" fill="currentColor">
|
||||
<path d="M61.44,0c33.93,0,61.44,27.51,61.44,61.44c0,33.93-27.51,61.44-61.44,61.44S0,95.37,0,61.44 C0,27.51,27.51,0,61.44,0L61.44,0z M61.17,61.6c1.76,0,3.18,1.42,3.18,3.18c0,1.76-1.42,3.18-3.18,3.18 c-1.76,0-3.18-1.42-3.18-3.18C57.99,63.03,59.42,61.6,61.17,61.6L61.17,61.6z M61.2,53.28c6.34,0,11.47,5.14,11.47,11.47 c0,6.34-5.14,11.47-11.47,11.47c-6.33,0-11.47-5.14-11.47-11.47C49.73,58.41,54.87,53.28,61.2,53.28L61.2,53.28z M14.78,44.57 c4.45-12.31,13.52-22.7,24.9-28.01c15.63-7.29,34.61-7.75,50.69,4.15c9.48,7.01,12.94,12.76,17.67,22.95 c3.58,9.03,0.64,11.97-10.87,6.9c-23.79-11.77-47.84-11.24-72.12,0C16.09,56.41,11.06,51.53,14.78,44.57L14.78,44.57z M75.9,109.05 c16.62-5.23,26.32-15.81,32.27-29.3c3.87-10.43-8.26-13.97-12.52-7.1c-2.55,5.06-5.59,9.4-9.55,12.77 c-6.2,5.27-15.18,6.23-16.58,16.16C68.79,106.74,69.97,111.38,75.9,109.05L75.9,109.05z M47.26,109.05 c-16.62-5.23-26.32-15.81-32.27-29.3c-3.87-10.43,8.26-13.97,12.52-7.1c2.55,5.06,5.59,9.4,9.55,12.77 c6.2,5.27,15.18,6.23,16.58,16.16C54.37,106.74,53.19,111.38,47.26,109.05L47.26,109.05z"/>
|
||||
</svg>
|
||||
<svg v-else-if="category.id === 'networking'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">{{ category.name }}</p>
|
||||
<p v-if="selectedCategory === category.id" class="text-xs text-white/60 mt-1">Currently viewing</p>
|
||||
</div>
|
||||
<svg v-if="selectedCategory === category.id" class="w-5 h-5 text-white flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
categories: { id: string; name: string }[]
|
||||
selectedCategory: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [categoryId: string]
|
||||
}>()
|
||||
|
||||
const showModal = ref(false)
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
restoreFocusRef.value?.focus?.()
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
useModalKeyboard(modalRef, showModal, close, { restoreFocusRef })
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import MarketplaceAppCard from '../MarketplaceAppCard.vue'
|
||||
import type { MarketplaceApp } from '../marketplaceData'
|
||||
|
||||
const app: MarketplaceApp = {
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '2.0.6',
|
||||
description: 'Self-hosted Bitcoin payments',
|
||||
icon: '',
|
||||
dockerImage: 'btcpayserver/btcpayserver:2.0.6',
|
||||
category: 'commerce',
|
||||
source: 'community',
|
||||
}
|
||||
|
||||
function mountCard(installed: boolean, installBlockedReason?: string) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
common: { install: 'Install', launch: 'Launch', notAvailable: 'Not available' },
|
||||
marketplace: { alreadyInstalled: 'Already installed' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(MarketplaceAppCard, {
|
||||
props: {
|
||||
app,
|
||||
index: 0,
|
||||
stagger: false,
|
||||
installed,
|
||||
installing: false,
|
||||
installProgress: undefined,
|
||||
installedState: installed ? 'running' : null,
|
||||
startingUp: false,
|
||||
containersScanned: true,
|
||||
tierLabel: 'recommended',
|
||||
installBlockedReason,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MarketplaceAppCard', () => {
|
||||
it('shows tier badges before install', () => {
|
||||
const wrapper = mountCard(false)
|
||||
expect(wrapper.find('.tier-badge').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('recommended')
|
||||
})
|
||||
|
||||
it('hides tier badges after install', () => {
|
||||
const wrapper = mountCard(true)
|
||||
expect(wrapper.find('.tier-badge').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('recommended')
|
||||
})
|
||||
|
||||
it('explains archive-node-only install blocks on cards', () => {
|
||||
const wrapper = mountCard(false, 'You need a full archival bitcoin node before downloading ElectrumX')
|
||||
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
|
||||
expect(wrapper.text()).toContain('Bitcoin Pruned')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Marketplace data: curated app list, categorization, aliases, and tier logic.
|
||||
* Extracted from Marketplace.vue to keep the view under 500 lines.
|
||||
*/
|
||||
|
||||
export interface MarketplaceApp {
|
||||
id: string
|
||||
title?: string
|
||||
version?: string
|
||||
description?: string | { short?: string; long?: string }
|
||||
icon?: string
|
||||
author?: string
|
||||
dockerImage?: string
|
||||
manifestUrl?: string
|
||||
repoUrl?: string
|
||||
webUrl?: string
|
||||
screenshots?: AppScreenshot[]
|
||||
category?: string
|
||||
source?: string
|
||||
url?: string
|
||||
s9pkUrl?: string
|
||||
containerConfig?: {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
trustScore?: number
|
||||
trustTier?: string
|
||||
relayCount?: number
|
||||
}
|
||||
|
||||
export type AppScreenshot = string | {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
id: string
|
||||
title: string
|
||||
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
attempt: number
|
||||
}
|
||||
|
||||
/** Archipelago app registry — all app images are mirrored here */
|
||||
const REGISTRY = '146.59.87.168:3000/lfg2025'
|
||||
|
||||
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
electrumx: ['electrumx', 'archy-electrs-ui'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
vaultwarden: ['vaultwarden'],
|
||||
searxng: ['searxng'],
|
||||
homeassistant: ['homeassistant'],
|
||||
photoprism: ['photoprism'],
|
||||
lnd: ['lnd', 'archy-lnd-ui'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
netbird: ['netbird'],
|
||||
ollama: ['ollama'],
|
||||
}
|
||||
|
||||
/** Get app tier classification (matches backend get_app_tier) */
|
||||
export function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
|
||||
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
}
|
||||
|
||||
/** Categorize community apps based on their ID and description */
|
||||
export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
if (app.category) return app.category
|
||||
|
||||
const id = app.id.toLowerCase()
|
||||
const title = app.title?.toLowerCase() || ''
|
||||
const description = (typeof app.description === 'string' ? app.description : (app.description as { short?: string })?.short ?? '').toLowerCase()
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') ||
|
||||
id.includes('lnd') || id.includes('cln') || id.includes('electr') ||
|
||||
id.includes('fedimint') || id.includes('cashu') || title.includes('lightning') ||
|
||||
combined.includes('wallet') || combined.includes('satoshi')) {
|
||||
return 'money'
|
||||
}
|
||||
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
|
||||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
|
||||
combined.includes('merchant') || combined.includes('invoice')) {
|
||||
return 'commerce'
|
||||
}
|
||||
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('sync') ||
|
||||
id.includes('storage') || id.includes('backup') || id.includes('file') ||
|
||||
id.includes('photo') || id.includes('immich') || id.includes('jellyfin') ||
|
||||
id.includes('plex') || id.includes('media') || id.includes('vault') ||
|
||||
combined.includes('password manager') || combined.includes('file storage')) {
|
||||
return 'data'
|
||||
}
|
||||
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') ||
|
||||
id.includes('smart-home') || id.includes('automation') || id.includes('iot') ||
|
||||
combined.includes('home automation') || combined.includes('smart home')) {
|
||||
return 'home'
|
||||
}
|
||||
|
||||
if (id.includes('nostr') || (id.includes('relay') && combined.includes('nostr')) ||
|
||||
combined.includes('nostr relay') || combined.includes('nostr client')) {
|
||||
return 'nostr'
|
||||
}
|
||||
|
||||
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') || id.includes('netbird') ||
|
||||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
|
||||
id.includes('adguard') || id.includes('nginx') || id.includes('tor') ||
|
||||
combined.includes('network') || combined.includes('firewall')) {
|
||||
return 'networking'
|
||||
}
|
||||
|
||||
if (id.includes('matrix') || id.includes('synapse') || id.includes('element') ||
|
||||
id.includes('mastodon') || id.includes('lemmy') ||
|
||||
id.includes('messenger') || id.includes('chat') || id.includes('social') ||
|
||||
id.includes('cups') || combined.includes('communication') ||
|
||||
combined.includes('messaging')) {
|
||||
return 'community'
|
||||
}
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Curated list of apps with Docker Hub images */
|
||||
export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{
|
||||
id: 'bitcoin-knots',
|
||||
title: 'Bitcoin Knots',
|
||||
version: '28.1.0',
|
||||
description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.',
|
||||
icon: '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
author: 'Bitcoin Knots',
|
||||
dockerImage: `${REGISTRY}/bitcoin-knots:latest`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/bitcoinknots/bitcoin'
|
||||
},
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '2.3.9',
|
||||
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
|
||||
icon: '/assets/img/app-icons/btcpay-server.png',
|
||||
author: 'BTCPay Server Foundation',
|
||||
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
title: 'LND',
|
||||
version: '0.18.4',
|
||||
description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.',
|
||||
icon: '/assets/img/app-icons/lnd.png',
|
||||
author: 'Lightning Labs',
|
||||
dockerImage: `${REGISTRY}/lnd:v0.18.4-beta`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/lightningnetwork/lnd'
|
||||
},
|
||||
{
|
||||
id: 'mempool',
|
||||
title: 'Mempool Explorer',
|
||||
version: '3.0.0',
|
||||
description: 'Self-hosted Bitcoin blockchain and mempool visualizer with beautiful explorer interface.',
|
||||
icon: '/assets/img/app-icons/mempool.webp',
|
||||
author: 'Mempool',
|
||||
dockerImage: `${REGISTRY}/mempool-frontend:v3.0.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/mempool/mempool'
|
||||
},
|
||||
{
|
||||
id: 'homeassistant',
|
||||
title: 'Home Assistant',
|
||||
version: '2024.1',
|
||||
description: 'Open-source home automation platform. Control and automate your smart home devices privately.',
|
||||
icon: '/assets/img/app-icons/homeassistant.png',
|
||||
author: 'Home Assistant',
|
||||
dockerImage: `${REGISTRY}/home-assistant:2024.1`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/home-assistant/core'
|
||||
},
|
||||
{
|
||||
id: 'grafana',
|
||||
title: 'Grafana',
|
||||
version: '10.2.0',
|
||||
description: 'Analytics and monitoring platform. Create dashboards and visualize data from multiple sources.',
|
||||
icon: '/assets/img/app-icons/grafana.png',
|
||||
author: 'Grafana Labs',
|
||||
dockerImage: `${REGISTRY}/grafana:10.2.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/grafana/grafana'
|
||||
},
|
||||
{
|
||||
id: 'searxng',
|
||||
title: 'SearXNG',
|
||||
version: '2024.1.0',
|
||||
description: 'Privacy-respecting metasearch engine. Search without tracking or ads.',
|
||||
icon: '/assets/img/app-icons/searxng.png',
|
||||
author: 'SearXNG',
|
||||
dockerImage: `${REGISTRY}/searxng:latest`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/searxng/searxng'
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
title: 'Ollama',
|
||||
version: '0.1.0',
|
||||
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: `${REGISTRY}/ollama:latest`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/ollama/ollama'
|
||||
},
|
||||
{
|
||||
id: 'cryptpad',
|
||||
title: 'CryptPad',
|
||||
version: '2024.12.0',
|
||||
description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.',
|
||||
icon: '/assets/icon/favico-black-v2.svg',
|
||||
author: 'XWiki SAS',
|
||||
dockerImage: `${REGISTRY}/cryptpad:2024.12.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/cryptpad/cryptpad'
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
title: 'Nextcloud',
|
||||
version: '29.0',
|
||||
description: 'Self-hosted cloud storage and collaboration platform. Your own private cloud.',
|
||||
icon: '/assets/img/app-icons/nextcloud.webp',
|
||||
author: 'Nextcloud',
|
||||
dockerImage: `${REGISTRY}/nextcloud:29`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/nextcloud/server'
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
title: 'Vaultwarden',
|
||||
version: '1.30.0',
|
||||
description: 'Self-hosted password manager (Bitwarden-compatible). Secure vault for passwords and secrets.',
|
||||
icon: '/assets/img/app-icons/vaultwarden.webp',
|
||||
author: 'Vaultwarden',
|
||||
dockerImage: `${REGISTRY}/vaultwarden:1.30.0-alpine`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/dani-garcia/vaultwarden'
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
title: 'Jellyfin',
|
||||
version: '10.8.0',
|
||||
description: 'Free media server system. Stream your movies, music, and photos to any device.',
|
||||
icon: '/assets/img/app-icons/jellyfin.webp',
|
||||
author: 'Jellyfin',
|
||||
dockerImage: `${REGISTRY}/jellyfin:10.8.13`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/jellyfin/jellyfin'
|
||||
},
|
||||
{
|
||||
id: 'photoprism',
|
||||
title: 'PhotoPrism',
|
||||
version: '240915',
|
||||
description: 'AI-powered photo management. Organize and browse photos with facial recognition.',
|
||||
icon: '/assets/img/app-icons/photoprism.svg',
|
||||
author: 'PhotoPrism',
|
||||
dockerImage: `${REGISTRY}/photoprism:240915`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/photoprism/photoprism'
|
||||
},
|
||||
{
|
||||
id: 'immich',
|
||||
title: 'Immich',
|
||||
version: '1.90.0',
|
||||
description: 'High-performance self-hosted photo and video backup. Mobile-first with ML features.',
|
||||
icon: '/assets/img/app-icons/immich.png',
|
||||
author: 'Immich',
|
||||
dockerImage: `${REGISTRY}/immich:release`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/immich-app/immich'
|
||||
},
|
||||
{
|
||||
id: 'electrumx',
|
||||
title: 'ElectrumX',
|
||||
version: '1.18.0',
|
||||
description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.',
|
||||
icon: '/assets/img/app-icons/electrumx.png',
|
||||
author: 'Luke Childs',
|
||||
dockerImage: `${REGISTRY}/electrumx:v1.18.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/spesmilo/electrumx'
|
||||
},
|
||||
{
|
||||
id: 'filebrowser',
|
||||
title: 'File Browser',
|
||||
version: '2.27.0',
|
||||
description: 'Web-based file manager. Browse, upload, and manage files through a web interface.',
|
||||
icon: '/assets/img/app-icons/file-browser.webp',
|
||||
author: 'File Browser',
|
||||
dockerImage: `${REGISTRY}/filebrowser:v2.27.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/filebrowser/filebrowser'
|
||||
},
|
||||
{
|
||||
id: 'nginx-proxy-manager',
|
||||
title: 'Nginx Proxy Manager',
|
||||
version: '2.11.0',
|
||||
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: `${REGISTRY}/nginx-proxy-manager:latest`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/NginxProxyManager/nginx-proxy-manager'
|
||||
},
|
||||
{
|
||||
id: 'portainer',
|
||||
title: 'Portainer',
|
||||
version: '2.19.0',
|
||||
description: 'Container management UI. Manage Docker containers through a beautiful web interface.',
|
||||
icon: '/assets/img/app-icons/portainer.webp',
|
||||
author: 'Portainer',
|
||||
dockerImage: `${REGISTRY}/portainer:latest`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/portainer/portainer'
|
||||
},
|
||||
{
|
||||
id: 'uptime-kuma',
|
||||
title: 'Uptime Kuma',
|
||||
version: '1.23.0',
|
||||
description: 'Self-hosted monitoring tool. Monitor uptime for HTTP(s), TCP, DNS, and more.',
|
||||
icon: '/assets/img/app-icons/uptime-kuma.webp',
|
||||
author: 'Uptime Kuma',
|
||||
dockerImage: `${REGISTRY}/uptime-kuma:1`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/louislam/uptime-kuma'
|
||||
},
|
||||
{
|
||||
id: 'tailscale',
|
||||
title: 'Tailscale',
|
||||
version: '1.78.0',
|
||||
description: 'Zero-config VPN for secure remote access. Connect all your devices with WireGuard mesh network.',
|
||||
icon: '/assets/img/app-icons/tailscale.webp',
|
||||
author: 'Tailscale',
|
||||
dockerImage: `${REGISTRY}/tailscale:stable`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/tailscale/tailscale'
|
||||
},
|
||||
{
|
||||
id: 'netbird',
|
||||
title: 'NetBird',
|
||||
version: '0.71.2',
|
||||
description: 'Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN.',
|
||||
icon: '/assets/img/app-icons/netbird.svg',
|
||||
author: 'NetBird',
|
||||
dockerImage: 'docker.io/netbirdio/dashboard:v2.38.0',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/netbirdio/netbird'
|
||||
},
|
||||
{
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
version: '0.10.0',
|
||||
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/app-icons/fedimint.png',
|
||||
author: 'Fedimint',
|
||||
dockerImage: `${REGISTRY}/fedimintd:v0.10.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||
},
|
||||
{
|
||||
id: 'indeedhub',
|
||||
title: 'Indeehub',
|
||||
version: '0.1.0',
|
||||
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
|
||||
icon: '/assets/img/app-icons/indeedhub.png',
|
||||
author: 'Indeehub Team',
|
||||
dockerImage: '146.59.87.168:3000/lfg2025/indeedhub:latest',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/indeedhub/indeedhub'
|
||||
},
|
||||
{
|
||||
id: 'nostrudel',
|
||||
title: 'noStrudel',
|
||||
version: '0.40.0',
|
||||
category: 'nostr',
|
||||
description: 'A feature-rich Nostr web client with NIP-07 signer support. Browse your feed, post notes, manage relays, and interact with the Nostr network — all signed with your node\'s Nostr identity.',
|
||||
icon: '/assets/img/app-icons/nostrudel.svg',
|
||||
author: 'hzrd149',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/hzrd149/nostrudel',
|
||||
webUrl: 'https://nostrudel.ninja'
|
||||
},
|
||||
{
|
||||
id: 'botfights',
|
||||
title: 'BotFights',
|
||||
version: '1.0.0',
|
||||
category: 'community',
|
||||
description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.',
|
||||
icon: '/assets/img/app-icons/botfights.svg',
|
||||
author: 'BotFights',
|
||||
dockerImage: `${REGISTRY}/botfights:1.1.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://botfights.net',
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
title: 'Gitea',
|
||||
version: '1.23',
|
||||
category: 'data',
|
||||
description: 'Self-hosted Git service with built-in container registry, CI/CD, issue tracking, and package hosting. Lightweight alternative to GitHub/GitLab.',
|
||||
icon: '/assets/img/app-icons/gitea.svg',
|
||||
author: 'Gitea',
|
||||
dockerImage: 'docker.io/gitea/gitea:1.23',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://gitea.com',
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user