2026-01-24 22:59:20 +00:00
< template >
< div class = "marketplace-container flex flex-col h-full overflow-hidden -mt-4 md:mt-0" >
<!-- Fixed Header Section -- >
< div class = "flex-shrink-0 -mt-4 md:mt-0" >
<!-- Installation Progress Banner -- >
< div v-if = "installing" class="mb-6 glass-card p-4 border-l-4 border-blue-500" >
< div class = "flex items-center justify-between" >
< div class = "flex items-center gap-3" >
< svg class = "animate-spin h-5 w-5 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 >
< div >
< p class = "text-white font-medium" > Installing { { installing } } ... < / p >
< p class = "text-white/70 text-sm" > Checking for Docker image and starting container < / p >
< / div >
< / div >
< div class = "text-white/60 text-sm" >
{ { installAttempt } } / { { maxAttempts } } s
< / div >
< / div >
< / div >
< div class = "mb-8 flex items-start justify-between" >
< div >
< h1 class = "hidden md:block text-4xl font-bold text-white mb-2" > App Store < / h1 >
< p class = "hidden md:block text-white/70" > Discover and install apps for your new sovereign life < / p >
< / div >
<!-- Sideload Button -- >
< button
@ click = "showSideloadModal = true"
class = "hidden md:flex px-6 py-3 gradient-button rounded-lg font-medium items-center gap-2"
>
< svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" / >
< / svg >
Sideload
< / button >
< / div >
<!-- Category Tabs ( Desktop only ) -- >
< div class = "hidden md:flex mb-6 glass-card p-2 rounded-lg flex-wrap gap-2" >
< button
v - for = "category in categories"
: key = "category.id"
@ click = "selectedCategory = category.id"
: class = " [
'px-6 py-2 rounded-lg font-medium transition-all' ,
selectedCategory === category . id
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white/80'
] "
>
{ { category . name } }
< / button >
< / div >
<!-- Search Bar -- >
< div class = "mb-6 pt-0 md:pt-0" >
< input
v - model = "searchQuery"
type = "text"
placeholder = "Search apps..."
class = "w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/ >
< / div >
< / div >
<!-- Scrollable Apps Section -- >
< div class = "flex-1 overflow-y-auto pr-2 -mr-2 pb-0 md:pb-6" >
<!-- Apps Grid -- >
< div class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" >
< div
v - for = "app in filteredApps"
: key = "app.id"
class = "glass-card p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
@ click = "viewAppDetails(app)"
>
< div class = "flex items-start gap-4 mb-4" >
< img
v - if = "app.icon"
: src = "app.icon"
: alt = "app.title"
class = "w-16 h-16 rounded-lg object-cover"
@ error = "handleImageError"
/ >
< div v -else class = "w-16 h-16 rounded-lg bg-white/10 flex items-center justify-center" >
< svg class = "w-8 h-8 text-white/40" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" / >
< / svg >
< / div >
< div class = "flex-1" >
< h3 class = "text-xl font-semibold text-white mb-1" > { { app . title } } < / h3 >
< p class = "text-sm text-white/60" > { { app . version ? ` v ${ app . version } ` : 'latest' } } < / p >
< p v-if = "app.author" class="text-xs text-white/50 mt-1" > by {{ app.author }} < / p >
< / div >
< / 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 >
< div class = "flex gap-2 mt-auto" >
< button
v - if = "isInstalled(app.id)"
disabled
class = "flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
>
Already Installed
< / button >
< button
v - else - if = "app.source === 'local' || app.manifestUrl"
@ click . stop = "app.source === 'local' ? installApp(app) : installCommunityApp(app)"
: disabled = "installing === app.id || installing !== null"
class = "flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
< span v-if = "installing === app.id" class="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 >
Installing ...
< / span >
< span v-else > Install < / span >
< / button >
< button
v - else
disabled
class = "flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
Not Available
< / button >
< a
v - if = "app.repoUrl"
: href = "app.repoUrl"
target = "_blank"
rel = "noopener noreferrer"
class = "px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm font-medium transition-all"
@ click . stop
>
GitHub
< / a >
< / div >
< / div >
< / div >
<!-- 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" >
< 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 >
< / div >
< p v -else class = "text-white/70" > No apps found in { { categories . find ( c => c . id === selectedCategory ) ? . name } } { { searchQuery ? ` matching " ${ searchQuery } " ` : '' } } < / p >
< / div >
< / div >
<!-- End Scrollable Apps Section -- >
<!-- Sideload Modal -- >
< Transition name = "modal" >
< div
v - if = "showSideloadModal"
class = "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@ click . self = "showSideloadModal = false"
>
< div class = "glass-card p-8 max-w-2xl w-full relative" >
<!-- Close Button -- >
< button
@ click = "showSideloadModal = false"
class = "absolute top-4 right-4 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 >
< h2 class = "text-2xl font-bold text-white mb-2" > Sideload Package < / h2 >
< p class = "text-white/70 mb-6" > Install a package from an s9pk file URL or local path < / p >
< div class = "flex flex-col gap-4" >
< input
v - model = "sideloadUrl"
type = "text"
placeholder = "https://example.com/package.s9pk or /packages/package.s9pk"
class = "w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40"
@ keyup . enter = "sideloadPackage"
/ >
< button
@ click = "sideloadPackage"
: disabled = "!sideloadUrl || sideloading"
class = "px-8 py-3 gradient-button rounded-lg font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
< svg v-if = "sideloading" class="animate-spin h-5 w-5" 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 >
{ { sideloading ? 'Installing...' : 'Install Package' } }
< / button >
< / div >
< p v-if = "sideloadError" class="mt-4 text-red-400 text-sm" > {{ sideloadError }} < / p >
< p v-if = "sideloadSuccess" class="mt-4 text-green-400 text-sm" > {{ sideloadSuccess }} < / p >
<!-- Examples -- >
< div class = "mt-6 p-4 bg-white/5 rounded-lg" >
< p class = "text-white/80 text-sm font-medium mb-2" > Examples : < / p >
< ul class = "text-white/60 text-sm space-y-1" >
< li > • < code class = "text-blue-400" > https : //github.com/.../releases/download/v1.0.0/app.s9pk</code></li>
< li > • < code class = "text-blue-400" > / packages / myapp . s9pk < / code > ( local file ) < / li >
< / ul >
< / div >
< / div >
< / div >
< / Transition >
<!-- Floating Filter Button ( Mobile only ) -- >
< button
@ click = "showFilterModal = true"
class = "md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl"
: style = " {
bottom : bottomPosition ,
filter : 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
} "
>
< 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 >
<!-- Filter Modal ( Mobile only ) -- >
< Transition name = "modal" >
< div
v - if = "showFilterModal"
class = "fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/60 backdrop-blur-sm"
@ click . self = "showFilterModal = false"
>
< div class = "glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto" >
<!-- Header -- >
< div class = "flex items-center justify-between mb-6" >
< h2 class = "text-2xl font-bold text-white" > Filter by Category < / h2 >
< button
@ click = "showFilterModal = false"
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-2 gap-3" >
< button
v - for = "category in categories"
: key = "category.id"
@ click = "selectedCategory = category.id; showFilterModal = false"
: 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 === '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 = = = ' 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 >
< / div >
< / template >
< script setup lang = "ts" >
import { ref , computed , onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useMobileBackButton } from '@/composables/useMobileBackButton'
const router = useRouter ( )
const store = useAppStore ( )
const { setCurrentApp } = useMarketplaceApp ( )
const { bottomPosition } = useMobileBackButton ( )
// Category state
const selectedCategory = ref ( 'community' )
const categories = [
{ id : 'community' , name : 'Community' } ,
{ id : 'commerce' , name : 'Commerce' } ,
{ id : 'money' , name : 'Money' } ,
{ id : 'data' , name : 'Data' } ,
{ id : 'home' , name : 'Home' } ,
{ id : 'car' , name : 'Auto' } ,
{ id : 'networking' , name : 'Networking' } ,
{ id : 'other' , name : 'Other' }
]
// Local apps state
const installing = ref < string | null > ( null )
const installAttempt = ref ( 0 )
const maxAttempts = ref ( 30 )
// Sideload modal state
const showSideloadModal = ref ( false )
const sideloadUrl = ref ( '' )
const sideloading = ref ( false )
const sideloadError = ref ( '' )
const sideloadSuccess = ref ( '' )
// Filter modal state (for mobile)
const showFilterModal = ref ( false )
// Community marketplace state
const loadingCommunity = ref ( false )
const communityError = ref ( '' )
const communityApps = ref < any [ ] > ( [ ] )
const searchQuery = ref ( '' )
// Available apps in marketplace
const availableApps = ref ( [
{
id : 'atob' ,
title : 'A to B Bitcoin' ,
version : '0.1.0' ,
icon : '/assets/img/atob.png' ,
category : 'community' ,
description : {
short : 'Bitcoin tools and services for seamless transactions' ,
long : 'A to B Bitcoin provides tools and services for Bitcoin transactions. Access the A to B platform through your Archipelago server with full privacy and control.'
} ,
s9pkUrl : '/packages/atob.s9pk'
} ,
{
id : 'k484' ,
title : 'K484' ,
version : '0.1.0' ,
icon : '/assets/img/k484.png' ,
category : 'commerce' ,
description : {
short : 'Point of Sale and Admin system for Archipelago' ,
long : 'K484 provides a complete POS and administration system for your Archipelago server. Choose between POS mode for transactions or Admin mode for management.'
} ,
s9pkUrl : '/packages/k484.s9pk'
} ,
{
id : 'btcpay' ,
title : 'BTCPay Server' ,
version : '0.1.0' ,
icon : '/assets/img/btcpay.png' ,
category : 'commerce' ,
description : {
short : 'Self-hosted Bitcoin payment processor' ,
long : 'BTCPay Server is a free, open-source cryptocurrency payment processor. Accept Bitcoin payments without intermediaries or fees. Complete merchant solution with invoicing, point of sale, and more.'
} ,
s9pkUrl : '/packages/btcpay.s9pk'
} ,
{
id : 'nextcloud' ,
title : 'Nextcloud' ,
version : '0.1.0' ,
icon : '/assets/img/nextcloud.png' ,
category : 'data' ,
description : {
short : 'Self-hosted file sync and collaboration platform' ,
long : 'Nextcloud provides file storage, sync, and sharing with calendar, contacts, mail, and office suite integration. Your own private cloud with complete control over your data.'
} ,
s9pkUrl : '/packages/nextcloud.s9pk'
} ,
{
id : 'home-assistant' ,
title : 'Home Assistant' ,
version : '0.1.0' ,
icon : '/assets/img/home-assistant.png' ,
category : 'home' ,
description : {
short : 'Open-source home automation platform' ,
long : 'Home Assistant is a powerful home automation hub that allows you to control and automate your smart home devices privately. No cloud required - everything runs locally on your Archipelago server.'
} ,
s9pkUrl : '/packages/home-assistant.s9pk'
}
] )
const installedPackages = computed ( ( ) => {
return store . data ? . [ 'package-data' ] || { }
} )
// Function to categorize community apps based on their ID and description
function categorizeCommunityApp ( app : any ) : string {
const id = app . id . toLowerCase ( )
const title = app . title ? . toLowerCase ( ) || ''
const description = app . description ? . toLowerCase ( ) || ''
const combined = ` ${ id } ${ title } ${ description } `
// Money category
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'
}
// Commerce category
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'
}
// Data category
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'
}
// Home category
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'
}
// Networking category
if ( id . includes ( 'vpn' ) || id . includes ( 'wireguard' ) || id . includes ( 'tailscale' ) ||
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'
}
// Community category
if ( id . includes ( 'matrix' ) || id . includes ( 'synapse' ) || id . includes ( 'element' ) ||
id . includes ( 'nostr' ) || 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'
}
// Default to other
return 'other'
}
// Combine local and community apps with categories
const allApps = computed ( ( ) => {
// Local apps already have categories - normalize field names
const local = availableApps . value . map ( app => ( {
... app ,
source : 'local' ,
// Add manifestUrl and url for consistency with community apps
manifestUrl : app . s9pkUrl ,
url : app . s9pkUrl
} ) )
// Categorize community apps intelligently
const community = communityApps . value . map ( app => {
const category = categorizeCommunityApp ( app )
return {
... app ,
category ,
source : 'community'
}
} )
return [ ... local , ... community ]
} )
// Filtered apps by category and search
const filteredApps = computed ( ( ) => {
let apps = allApps . value
// Filter by category
if ( selectedCategory . value && selectedCategory . value !== 'all' ) {
apps = apps . filter ( app => app . category === selectedCategory . value )
}
// Filter by search query
if ( searchQuery . value ) {
const query = searchQuery . value . toLowerCase ( )
apps = apps . filter ( app =>
app . title ? . toLowerCase ( ) . includes ( query ) ||
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 )
)
}
return apps
} )
// Keep for backward compatibility
// @ts-ignore - Computed kept for backward compatibility
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const filteredCommunityApps = computed ( ( ) => {
return communityApps . value
} )
function isInstalled ( appId : string ) : boolean {
return appId in installedPackages . value
}
// Load community marketplace on mount
onMounted ( ( ) => {
if ( communityApps . value . length === 0 && ! loadingCommunity . value ) {
loadCommunityMarketplace ( )
}
} )
// Load community marketplace from Start9 registry
async function loadCommunityMarketplace ( ) {
loadingCommunity . value = true
communityError . value = ''
2026-01-27 22:55:20 +00:00
// Use curated list of Docker-based apps
// These are standard Docker images, not StartOS packages
console . log ( '📦 Loading Docker-based app marketplace' )
communityApps . value = getCuratedAppList ( )
loadingCommunity . value = false
}
// Curated list of apps with Docker Hub images
function getCuratedAppList ( ) {
return [
{
id : 'bitcoin' ,
title : 'Bitcoin Core' ,
version : '27.0.0' ,
description : 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.' ,
icon : '/assets/img/app-icons/bitcoin.svg' ,
author : 'Bitcoin Core' ,
dockerImage : 'lncm/bitcoind:v27.0' ,
manifestUrl : null ,
repoUrl : 'https://github.com/bitcoin/bitcoin'
} ,
{
id : 'btcpay-server' ,
title : 'BTCPay Server' ,
version : '1.13.5' ,
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 : 'btcpayserver/btcpayserver:1.13.5' ,
manifestUrl : null ,
repoUrl : 'https://github.com/btcpayserver/btcpayserver'
} ,
{
id : 'lnd' ,
title : 'LND' ,
version : '0.17.4' ,
description : 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.' ,
icon : '/assets/img/app-icons/lightning-stack.png' ,
author : 'Lightning Labs' ,
dockerImage : 'lightninglabs/lnd:v0.17.4-beta' ,
manifestUrl : null ,
repoUrl : 'https://github.com/lightningnetwork/lnd'
} ,
{
id : 'mempool' ,
title : 'Mempool Explorer' ,
version : '2.5.0' ,
description : 'Self-hosted Bitcoin blockchain and mempool visualizer with beautiful explorer interface.' ,
icon : '/assets/img/app-icons/mempool.png' ,
author : 'Mempool' ,
dockerImage : 'mempool/frontend:v2.5.0' ,
manifestUrl : null ,
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 : 'homeassistant/home-assistant:2024.1' ,
manifestUrl : null ,
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/grafana.png' ,
author : 'Grafana Labs' ,
dockerImage : 'grafana/grafana:10.2.0' ,
manifestUrl : null ,
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 : 'searxng/searxng:latest' ,
manifestUrl : null ,
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/ollama.webp' ,
author : 'Ollama' ,
dockerImage : 'ollama/ollama:latest' ,
manifestUrl : null ,
repoUrl : 'https://github.com/ollama/ollama'
} ,
{
id : 'onlyoffice' ,
title : 'OnlyOffice' ,
version : '7.5.1' ,
description : 'Office suite for document collaboration. Edit docs, spreadsheets, and presentations.' ,
icon : '/assets/img/onlyoffice.webp' ,
author : 'Ascensio System SIA' ,
dockerImage : 'onlyoffice/documentserver:7.5.1' ,
manifestUrl : null ,
repoUrl : 'https://github.com/ONLYOFFICE/DocumentServer'
} ,
{
id : 'penpot' ,
title : 'Penpot' ,
version : '2.0.0' ,
description : 'Open-source design and prototyping platform. Self-hosted alternative to Figma.' ,
icon : '/assets/img/penpot.webp' ,
author : 'Penpot' ,
dockerImage : 'penpotapp/frontend:latest' ,
manifestUrl : null ,
repoUrl : 'https://github.com/penpot/penpot'
} ,
{
id : 'nextcloud' ,
title : 'Nextcloud' ,
version : '28.0' ,
description : 'Self-hosted cloud storage and collaboration platform. Your own private cloud.' ,
icon : null ,
author : 'Nextcloud' ,
dockerImage : 'nextcloud:28' ,
manifestUrl : null ,
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 : null ,
author : 'Vaultwarden' ,
dockerImage : 'vaultwarden/server:1.30.0-alpine' ,
manifestUrl : null ,
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 : null ,
author : 'Jellyfin' ,
dockerImage : 'jellyfin/jellyfin:10.8.13' ,
manifestUrl : null ,
repoUrl : 'https://github.com/jellyfin/jellyfin'
} ,
{
id : 'photoprism' ,
title : 'PhotoPrism' ,
version : '231128' ,
description : 'AI-powered photo management. Organize and browse photos with facial recognition.' ,
icon : null ,
author : 'PhotoPrism' ,
dockerImage : 'photoprism/photoprism:latest' ,
manifestUrl : null ,
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 : null ,
author : 'Immich' ,
dockerImage : 'ghcr.io/immich-app/immich-server:release' ,
manifestUrl : null ,
repoUrl : 'https://github.com/immich-app/immich'
} ,
{
id : 'filebrowser' ,
title : 'File Browser' ,
version : '2.27.0' ,
description : 'Web-based file manager. Browse, upload, and manage files through a web interface.' ,
icon : null ,
author : 'File Browser' ,
dockerImage : 'filebrowser/filebrowser:v2.27.0' ,
manifestUrl : null ,
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 : null ,
author : 'Nginx Proxy Manager' ,
dockerImage : 'jc21/nginx-proxy-manager:latest' ,
manifestUrl : null ,
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 : null ,
author : 'Portainer' ,
dockerImage : 'portainer/portainer-ce:2.19.4' ,
manifestUrl : null ,
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 : null ,
author : 'Uptime Kuma' ,
dockerImage : 'louislam/uptime-kuma:1.23.11' ,
manifestUrl : null ,
repoUrl : 'https://github.com/louislam/uptime-kuma'
} ,
{
id : 'fedimint' ,
title : 'Fedimint' ,
version : '0.3.0' ,
description : 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.' ,
icon : '/assets/img/icon-fedimint.jpeg' ,
author : 'Fedimint' ,
dockerImage : 'fedimint/fedimintd:v0.3.0' ,
manifestUrl : null ,
repoUrl : 'https://github.com/fedimint/fedimint'
2026-01-24 22:59:20 +00:00
}
2026-01-27 22:55:20 +00:00
]
}
2026-01-24 22:59:20 +00:00
function viewAppDetails ( app : any ) {
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' )
router . push ( {
name : 'app-details' ,
params : { id : app . id }
} )
} else {
// Store app data in composable for marketplace detail view
setCurrentApp ( app )
console . log ( '[Marketplace] App data stored in composable' )
// Navigate to marketplace detail page
router . push ( {
name : 'marketplace-app-detail' ,
params : { id : app . id }
} )
}
} catch ( e ) {
console . error ( '[Marketplace] Navigation error:' , e )
}
}
async function installApp ( app : any ) {
if ( installing . value || isInstalled ( app . id ) ) return
installing . value = app . id
try {
const installUrl = app . url || app . manifestUrl || app . s9pkUrl
console . log ( '[Marketplace] Installing local app:' , { id : app . id , url : installUrl , version : app . version } )
await rpcClient . call ( {
method : 'package.install' ,
params : {
id : app . id ,
url : installUrl ,
version : app . version
}
} )
// Wait for installation to complete (poll for package to appear)
installAttempt . value = 0
const checkInstalled = setInterval ( ( ) => {
installAttempt . value ++
if ( isInstalled ( app . id ) ) {
clearInterval ( checkInstalled )
installing . value = null
installAttempt . value = 0
setTimeout ( ( ) => {
router . push ( '/dashboard/apps' )
} , 500 )
} else if ( installAttempt . value >= maxAttempts . value ) {
clearInterval ( checkInstalled )
installing . value = null
installAttempt . value = 0
alert ( 'Installation timeout. Please check the backend logs or try refreshing the page.' )
}
} , 1000 )
} catch ( err ) {
console . error ( 'Installation failed:' , err )
alert ( ` Failed to install ${ app . title } : ${ err } ` )
installing . value = null
installAttempt . value = 0
}
}
async function installCommunityApp ( app : any ) {
if ( installing . value || isInstalled ( app . id ) || ! app . manifestUrl ) return
installing . value = app . id
try {
2026-01-27 22:55:20 +00:00
console . log ( ` [Marketplace] Installing Docker app ${ app . title } using image ${ app . dockerImage } ` )
2026-01-24 22:59:20 +00:00
await rpcClient . call ( {
method : 'package.install' ,
params : {
id : app . id ,
2026-01-27 22:55:20 +00:00
dockerImage : app . dockerImage ,
2026-01-24 22:59:20 +00:00
version : app . version
}
} )
// Wait for installation to complete (poll for package to appear)
installAttempt . value = 0
const checkInstalled = setInterval ( ( ) => {
installAttempt . value ++
if ( isInstalled ( app . id ) ) {
clearInterval ( checkInstalled )
installing . value = null
installAttempt . value = 0
setTimeout ( ( ) => {
router . push ( '/dashboard/apps' )
} , 500 )
} else if ( installAttempt . value >= maxAttempts . value ) {
clearInterval ( checkInstalled )
installing . value = null
installAttempt . value = 0
2026-01-27 22:55:20 +00:00
console . error ( '[Marketplace] Installation timeout' )
2026-01-24 22:59:20 +00:00
}
} , 1000 )
} catch ( err ) {
2026-01-27 22:55:20 +00:00
console . error ( '[Marketplace] Installation failed:' , err )
2026-01-24 22:59:20 +00:00
installing . value = null
installAttempt . value = 0
}
}
async function sideloadPackage ( ) {
if ( ! sideloadUrl . value || sideloading . value ) return
sideloading . value = true
sideloadError . value = ''
sideloadSuccess . value = ''
try {
console . log ( ` Sideloading package from ${ sideloadUrl . value } ... ` )
await rpcClient . call ( {
method : 'package.sideload' ,
params : {
url : sideloadUrl . value
}
} )
sideloadSuccess . value = 'Package installed successfully!'
sideloadUrl . value = ''
// Close modal and navigate to apps page after short delay
setTimeout ( ( ) => {
showSideloadModal . value = false
router . push ( '/dashboard/apps' )
} , 1500 )
} catch ( err : any ) {
console . error ( 'Sideload failed:' , err )
sideloadError . value = err . message || 'Failed to install package'
} finally {
sideloading . value = false
}
}
function handleImageError ( event : Event ) {
const img = event . target as HTMLImageElement
img . src = '/assets/img/logo-archipelago.svg'
}
< / script >
< style scoped >
. marketplace - container {
max - width : 1400 px ;
margin : 0 auto ;
}
. line - clamp - 3 {
display : - webkit - box ;
- webkit - line - clamp : 3 ;
- webkit - box - orient : vertical ;
overflow : hidden ;
}
/* Modal transition animations */
. modal - enter - active ,
. modal - leave - active {
transition : opacity 0.3 s ease ;
}
. modal - enter - from ,
. modal - leave - to {
opacity : 0 ;
}
. modal - enter - active . glass - card ,
. modal - leave - active . glass - card {
transition : transform 0.3 s ease ;
}
. modal - enter - from . glass - card {
transform : scale ( 0.95 ) ;
}
. modal - leave - to . glass - card {
transform : scale ( 0.95 ) ;
}
/* Custom scrollbar styling for apps section */
. marketplace - container : : - webkit - scrollbar {
width : 8 px ;
}
. marketplace - container : : - webkit - scrollbar - track {
background : rgba ( 255 , 255 , 255 , 0.05 ) ;
border - radius : 4 px ;
}
. marketplace - container : : - webkit - scrollbar - thumb {
background : rgba ( 255 , 255 , 255 , 0.2 ) ;
border - radius : 4 px ;
transition : background 0.3 s ease ;
}
. marketplace - container : : - webkit - scrollbar - thumb : hover {
background : rgba ( 255 , 255 , 255 , 0.3 ) ;
}
/* Firefox scrollbar */
. marketplace - container {
scrollbar - width : thin ;
scrollbar - color : rgba ( 255 , 255 , 255 , 0.2 ) rgba ( 255 , 255 , 255 , 0.05 ) ;
}
< / style >