Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 624a2418ed
1578 changed files with 333060 additions and 0 deletions
@@ -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">&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>
<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',
},
]
}