feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
< template >
< div class = "discover-container" >
<!-- Navigation Bar ( always at top ) -- >
< div >
<!-- Desktop : tabs + categories + search -- >
< div class = "hidden md:flex mb-6 items-center gap-4" >
< div class = "mode-switcher flex-shrink-0" >
< RouterLink to = "/dashboard/apps" class = "mode-switcher-btn" > My Apps < / RouterLink >
2026-03-19 16:12:01 +00:00
< RouterLink to = "/dashboard/discover" class = "mode-switcher-btn mode-switcher-btn-active" > App Store < / RouterLink >
feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
< RouterLink to = "/dashboard/apps?tab=services" class = "mode-switcher-btn" > Services < / RouterLink >
< / div >
< div class = "mode-switcher flex-shrink-0" >
< RouterLink
to = "/dashboard/discover"
2026-03-19 16:12:01 +00:00
class = "mode-switcher-btn"
: class = "{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
> Discover < / RouterLink >
< button
v - for = "category in categoriesWithApps"
: key = "category.id"
@ click = "navigateToMarketplace(category.id)"
class = "mode-switcher-btn"
>
{ { category . name } }
< / button >
< / div >
< input
v - model = "searchQuery"
type = "text"
placeholder = "Search apps..."
aria - label = "Search apps"
class = "flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/ >
< / div >
<!-- Mobile : search -- >
< div class = "md:hidden mb-4" >
< input
v - model = "searchQuery"
type = "text"
placeholder = "Search apps..."
aria - label = "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 >
<!-- Hero Section -- >
< div v-if = "!searchQuery" class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden" >
< div class = "discover-hero-scanline" aria -hidden = " true " > < / div >
< div class = "relative z-10" >
< div class = "flex items-center gap-3 mb-4" >
< span class = "discover-terminal-tag" > ~ $ < / span >
< span class = "text-white/40 text-sm font-mono tracking-wider" > ARCHIPELAGO : //DISCOVER</span>
< / div >
< h1 class = "text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago" >
Reclaim Your < br / >
< span class = "discover-hero-accent" > Digital Sovereignty < / span >
< / h1 >
< p class = "text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6" >
Your node . Your rules . Every app runs on < em > your < / em > hardware , verified by < em > your < / em > Bitcoin node .
No cloud . No custodians . No permission needed .
< / p >
< div class = "flex flex-wrap gap-4 text-sm" >
< div class = "discover-stat-pill" >
< span class = "text-white font-bold" > { { allApps . length } } < / span >
< span class = "text-white/50" > apps available < / span >
< / div >
< div class = "discover-stat-pill" >
< span class = "text-white font-bold" > { { installedCount } } < / span >
< span class = "text-white/50" > installed < / span >
< / div >
< div class = "discover-stat-pill" >
< span class = "text-white font-bold" > 100 % < / span >
< span class = "text-white/50" > self - hosted < / span >
< / div >
< / div >
< / div >
< / div >
2026-03-19 16:44:46 +00:00
<!-- Install progress shown on cards inline , no separate banner -- >
feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
<!-- Featured Apps Section ( only when no search ) -- >
< div v-if = "!searchQuery" class="mb-10" >
< div class = "flex items-center gap-3 mb-5" >
< span class = "discover-terminal-tag" > featured < / span >
< h2 class = "text-xl font-bold text-white" > Sovereignty Stack < / h2 >
< div class = "flex-1 h-px bg-white/10" > < / div >
< / div >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-6 mb-8" >
< div
v - for = "(app, index) in featuredApps"
: key = "app.id"
data - controller - container
tabindex = "0"
role = "link"
class = "discover-featured-card glass-card p-6 cursor-pointer"
: class = "{ 'card-stagger': showStagger }"
: style = "{ '--stagger-index': index }"
@ click = "viewAppDetails(app)"
@ keydown . enter = "viewAppDetails(app)"
>
< div class = "flex items-start gap-5" >
< img
v - if = "app.icon"
: src = "app.icon"
: alt = "app.title"
class = "w-20 h-20 rounded-xl object-cover flex-shrink-0"
@ error = "handleImageError"
/ >
< div class = "flex-1 min-w-0" >
< div class = "flex items-center gap-2 mb-1" >
< h3 class = "text-xl font-bold text-white truncate" > { { app . title } } < / h3 >
< span
v - if = "getAppTier(app.id) !== 'optional'"
class = "tier-badge"
: class = "getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
> { { getAppTier ( app . id ) } } < / span >
< span v-if = "isInstalled(app.id)" class="discover-installed-badge" > installed < / span >
< / div >
< p class = "text-white/50 text-sm mb-3" > { { app . author } } & middot ; v { { app . version } } < / p >
< p class = "text-white/80 text-sm leading-relaxed" > { { app . featuredDescription } } < / p >
< / div >
< / div >
< div class = "flex items-center justify-between mt-4 pt-4 border-t border-white/8" >
< div class = "flex items-center gap-2" >
< svg class = "w-4 h-4 text-orange-400/70" fill = "currentColor" viewBox = "0 0 20 20" >
< path fill -rule = " evenodd " d = "M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip -rule = " evenodd " / >
< / svg >
< span class = "text-white/40 text-xs font-mono" > { { app . privacyTag } } < / span >
< / div >
< button
v - if = "isInstalled(app.id) && !isStartingUp(app.id)"
@ click . stop = "launchInstalledApp(app)"
class = "glass-button glass-button-sm rounded-lg text-sm font-medium"
> Launch < / button >
< span
v - else - if = "isInstalled(app.id) && isStartingUp(app.id)"
class = "text-yellow-200 text-sm flex items-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 >
Starting ...
< / span >
< button
v - else - if = "!containersScanned && app.dockerImage"
disabled
class = "text-white/40 text-sm flex items-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 ...
< / button >
< button
v - else - if = "!isInstalled(app.id) && app.dockerImage"
data - controller - install - btn
@ click . stop = "app.source === 'local' ? installApp(app) : installCommunityApp(app)"
: disabled = "installingApps.has(app.id)"
class = "glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
< span v-if = "installingApps.has(app.id)" class="flex items-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 >
< / div >
< / div >
< / div >
<!-- Principles Row -- >
< div class = "grid grid-cols-2 md:grid-cols-4 gap-4 mb-10" >
< div class = "discover-principle-card" >
< svg class = "w-6 h-6 text-orange-400 mb-2" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" / >
< / svg >
< h3 class = "text-white text-sm font-bold mb-1" > Privacy First < / h3 >
< p class = "text-white/40 text-xs leading-relaxed" > No telemetry . No tracking . Your data never leaves your hardware . < / p >
< / div >
< div class = "discover-principle-card" >
< svg class = "w-6 h-6 text-orange-400 mb-2" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" / >
< / svg >
< h3 class = "text-white text-sm font-bold mb-1" > Verify , Don ' t Trust < / h3 >
< p class = "text-white/40 text-xs leading-relaxed" > Run your own node . Validate every transaction . Be your own bank . < / p >
< / div >
< div class = "discover-principle-card" >
< svg class = "w-6 h-6 text-orange-400 mb-2" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" / >
< / svg >
< h3 class = "text-white text-sm font-bold mb-1" > Open Source < / h3 >
< p class = "text-white/40 text-xs leading-relaxed" > Every app is open source . Audit the code . Trust the math , not the company . < / p >
< / div >
< div class = "discover-principle-card" >
< svg class = "w-6 h-6 text-orange-400 mb-2" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" / >
< / svg >
< h3 class = "text-white text-sm font-bold mb-1" > No Permission Needed < / h3 >
< p class = "text-white/40 text-xs leading-relaxed" > Permissionless commerce . Permissionless money . Permissionless freedom . < / p >
< / div >
< / div >
<!-- Category Section Divider -- >
< div class = "flex items-center gap-3 mb-5" >
< span class = "discover-terminal-tag" > all < / span >
< h2 class = "text-xl font-bold text-white" > All Applications < / h2 >
< div class = "flex-1 h-px bg-white/10" > < / div >
< span class = "text-white/30 text-sm" > { { filteredApps . length } } apps < / span >
< / div >
< / div >
<!-- Search results header -- >
< div v -else class = "flex items-center gap-3 mb-5" >
< span class = "discover-terminal-tag" > search < / span >
< h2 class = "text-xl font-bold text-white" > Search Results < / h2 >
< div class = "flex-1 h-px bg-white/10" > < / div >
< span class = "text-white/30 text-sm" > { { filteredApps . length } } apps < / span >
< / div >
<!-- Community Load Error -- >
< div v-if = "communityError" class="alert-error mb-4" >
{ { communityError } }
< button @click ="loadCommunityMarketplace()" class = "ml-2 underline hover:no-underline" > Retry < / button >
< / div >
<!-- Apps Grid -- >
< div class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8" >
< div
v - for = "(app, index) in filteredApps"
: key = "app.id"
data - controller - container
: data - controller - install = "!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex = "0"
role = "link"
class = "discover-app-card glass-card p-5 cursor-pointer flex flex-col"
: class = "{ 'card-stagger': showStagger }"
: style = "{ '--stagger-index': index + (selectedCategory === 'all' && !searchQuery ? 4 : 0) }"
@ click = "viewAppDetails(app)"
@ keydown . enter = "viewAppDetails(app)"
>
< div class = "flex items-start gap-4 mb-3" >
< img
v - if = "app.icon"
: src = "app.icon"
: alt = "app.title"
class = "w-14 h-14 rounded-lg object-cover"
@ error = "handleImageError"
/ >
< div v -else class = "w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center" >
< svg class = "w-7 h-7 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 min-w-0" >
< div class = "flex items-center gap-2 mb-0.5" >
< h3 class = "text-lg font-semibold text-white truncate" > { { app . title } } < / h3 >
< span
v - if = "getAppTier(app.id) !== 'optional'"
class = "tier-badge"
: class = "getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
> { { getAppTier ( app . id ) } } < / span >
< / div >
< p class = "text-sm text-white/50" > { { app . version ? ` v ${ app . version } ` : 'latest' } } < / p >
< p v-if = "app.author" class="text-xs text-white/40 mt-0.5" > {{ app.author }} < / p >
< / div >
< / div >
<!-- Trust badge for Nostr apps -- >
< div v-if = "app.trustTier" class="flex items-center gap-2 mb-2" >
< 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 >
< / div >
< p class = "text-white/70 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" >
<!-- Installed & starting up -- >
< span
v - if = "isInstalled(app.id) && isStartingUp(app.id)"
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 >
{ { getInstalledState ( app . id ) === 'installing' ? 'Installing...' : 'Starting...' } }
< / span >
<!-- Installed & ready -- >
< span
v - else - if = "isInstalled(app.id)"
class = "flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
> Installed < / span >
< button
v - if = "isInstalled(app.id) && !isStartingUp(app.id)"
@ click . stop = "launchInstalledApp(app)"
class = "px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
> Launch < / button >
<!-- Scanning -- >
< 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 = "discover-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 >
<!-- Install button -- >
< button
v - else - if = "!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
data - controller - install - btn
@ click . stop = "app.source === 'local' ? installApp(app) : installCommunityApp(app)"
: disabled = "installingApps.has(app.id)"
class = "flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
< span v-if = "installingApps.has(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 >
{ { installingApps . get ( app . id ) ? . message || 'Installing...' } }
< / span >
< span v-else > Install < / span >
< / button >
<!-- Not available -- >
< button
v - else - if = "!isInstalled(app.id)"
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 >
< / div >
< / div >
< / div >
<!-- Empty State -- >
< div v-if = "filteredApps.length === 0" class="text-center py-12" >
< div v-if = "loadingCommunity || nostrLoading" class="flex flex-col items-center gap-4" >
< svg class = "animate-spin h-12 w-12 text-blue-400" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
< circle class = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" stroke -width = " 4 " > < / circle >
< path class = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > < / path >
< / svg >
< p class = "text-white/70" > { { nostrLoading ? 'Querying Nostr relays...' : 'Loading...' } } < / p >
< / div >
< div v -else -if = " nostrError & & selectedCategory = = = ' nostr ' " class = "flex flex-col items-center gap-4" >
< p class = "text-white/70" > No community apps found < / p >
< p class = "text-white/40 text-sm" > { { nostrError } } < / p >
< button @ click = "nostrApps = []; loadNostrMarketplace()" class = "px-4 py-2 glass-button rounded-lg text-sm" > Retry < / button >
< / div >
< p v -else class = "text-white/70" > No apps found { { searchQuery ? ` for " ${ searchQuery } " ` : '' } } < / p >
< / div >
<!-- Manifesto Footer ( only when no search ) -- >
< div v-if = "!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8" >
< div class = "flex items-center gap-3 mb-4" >
< span class = "discover-terminal-tag text-orange-400/80" > manifesto < / span >
< div class = "flex-1 h-px bg-white/10" > < / div >
< / div >
< blockquote class = "text-white/60 text-sm leading-relaxed italic max-w-3xl" >
" Privacy is not about having something to hide . Privacy is about having the right to choose
what to reveal . In a world of surveillance capitalism , self - hosting is an act of resistance .
Every service you run on your own hardware is a vote for a future where individuals & mdash ; not
corporations & mdash ; control their digital lives . "
< / blockquote >
< p class = "text-white/30 text-xs mt-4 font-mono" > // Cypherpunks write code. We run nodes.</p>
< / div >
<!-- Floating Filter Button ( Mobile ) -- >
< Teleport to = "body" >
< 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 mobile-back-btn"
style = "left: auto;"
>
< 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 ) -- >
< Transition name = "modal" >
< div
v - if = "showFilterModal"
class = "fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@ click . self = "closeFilterModal()"
>
< div ref = "filterModalRef" class = "glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto" >
< div class = "flex items-center justify-between mb-6" >
< h2 class = "text-2xl font-bold text-white" > Filter < / h2 >
< button @click ="closeFilterModal()" 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 >
< div class = "grid grid-cols-1 sm:grid-cols-2 gap-3" >
< button
v - for = "category in categoriesWithApps"
: key = "category.id"
@ click = "selectCategory(category.id); closeFilterModal()"
: class = " [
'p-4 rounded-xl font-medium transition-all text-left' ,
selectedCategory === category . id
? 'bg-white/20 text-white border-2 border-white/40'
2026-03-19 17:54:26 +00:00
: 'glass-card text-white/80 hover:bg-orange-500/5 hover:border-orange-500/15'
feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
] "
>
< div class = "flex items-center gap-3" >
< 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 lang = "ts" >
let discoverAnimationDone = false
< / script >
< script setup lang = "ts" >
import { ref , computed , onMounted , onBeforeUnmount , watch } from 'vue'
import { useRouter , RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp , type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
type MarketplaceApp = Partial < MarketplaceAppInfo > & { id : string ; trustScore ? : number ; trustTier ? : string ; relayCount ? : number }
type FeaturedApp = MarketplaceApp & { featuredDescription : string ; privacyTag : string }
const router = useRouter ( )
const store = useAppStore ( )
const showStagger = ! discoverAnimationDone
const { setCurrentApp } = useMarketplaceApp ( )
const appLauncher = useAppLauncherStore ( )
const selectedCategory = ref ( 'all' )
const searchQuery = ref ( '' )
const categories = computed ( ( ) => [
{ id : 'all' , name : 'All' } ,
{ id : 'community' , name : 'Community' } ,
{ id : 'nostr' , name : 'Nostr' } ,
{ id : 'commerce' , name : 'Commerce' } ,
{ id : 'money' , name : 'Money' } ,
{ id : 'data' , name : 'Data' } ,
{ id : 'home' , name : 'Home' } ,
{ id : 'networking' , name : 'Networking' } ,
{ id : 'l484' , name : 'L484' } ,
{ id : 'other' , name : 'Other' }
] )
// Installation state
interface InstallProgress {
id : string
title : string
status : 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
progress : number
message : string
attempt : number
}
const installingApps = ref < Map < string , InstallProgress > > ( new Map ( ) )
const maxAttempts = ref ( 60 )
watch ( ( ) => store . packages , ( packages ) => {
if ( ! packages ) return
for ( const [ appId , pkg ] of Object . entries ( packages ) ) {
const progress = pkg [ 'install-progress' ]
if ( progress && pkg . state === 'installing' && installingApps . value . has ( appId ) ) {
const current = installingApps . value . get ( appId ) !
const pct = progress . size > 0 ? Math . round ( ( progress . downloaded / progress . size ) * 100 ) : 0
const downloadedMB = ( progress . downloaded / ( 1024 * 1024 ) ) . toFixed ( 1 )
const totalMB = ( progress . size / ( 1024 * 1024 ) ) . toFixed ( 1 )
installingApps . value . set ( appId , {
... current ,
status : 'downloading' ,
progress : Math . min ( pct , 95 ) ,
message : progress . size > 0 ? ` Downloading: ${ downloadedMB } / ${ totalMB } MB ( ${ pct } %) ` : 'Downloading...' ,
} )
}
}
} , { deep : true } )
// Filter modal
const showFilterModal = ref ( false )
const filterModalRef = ref < HTMLElement | null > ( null )
const filterRestoreFocusRef = ref < HTMLElement | null > ( null )
function closeFilterModal ( ) {
filterRestoreFocusRef . value ? . focus ? . ( )
showFilterModal . value = false
}
useModalKeyboard ( filterModalRef , showFilterModal , closeFilterModal , { restoreFocusRef : filterRestoreFocusRef } )
function selectCategory ( id : string ) {
selectedCategory . value = id
if ( id === 'nostr' && nostrApps . value . length === 0 && ! nostrLoading . value ) {
loadNostrMarketplace ( )
}
}
function navigateToMarketplace ( categoryId : string ) {
router . push ( { name : 'marketplace' , query : { category : categoryId } } )
}
// Community & Nostr marketplace state
const loadingCommunity = ref ( false )
const communityError = ref ( '' )
const communityApps = ref < MarketplaceApp [ ] > ( [ ] )
const nostrApps = ref < ( MarketplaceApp & { trustScore ? : number ; trustTier ? : string ; relayCount ? : number } ) [ ] > ( [ ] )
const nostrLoading = ref ( false )
const nostrError = ref ( '' )
async function loadNostrMarketplace ( ) {
if ( nostrApps . value . length > 0 || nostrLoading . value ) return
nostrLoading . value = true
nostrError . value = ''
try {
const res = await rpcClient . marketplaceDiscover ( )
nostrApps . value = res . apps . map ( app => ( {
id : app . manifest . app _id ,
title : app . manifest . name ,
version : app . manifest . version ,
description : typeof app . manifest . description === 'string'
? app . manifest . description
: app . manifest . description ,
icon : app . manifest . icon _url || '' ,
author : app . manifest . author . name ,
dockerImage : app . manifest . container . image ,
repoUrl : app . manifest . repo _url ,
category : app . manifest . category ,
source : 'nostr' ,
trustScore : app . trust _score ,
trustTier : app . trust _tier ,
relayCount : app . relay _count ,
} ) )
} catch ( e ) {
nostrError . value = e instanceof Error ? e . message : 'Discovery failed'
if ( import . meta . env . DEV ) console . warn ( 'Nostr marketplace discovery failed:' , e )
} finally {
nostrLoading . value = false
}
}
const installedPackages = computed ( ( ) => store . data ? . [ 'package-data' ] || { } )
const containersScanned = computed ( ( ) => store . data ? . [ 'server-info' ] ? . [ 'status-info' ] ? . [ 'containers-scanned' ] ? ? false )
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 ? . short ? ? '' ) . toLowerCase ( )
const combined = ` ${ id } ${ title } ${ description } `
if ( id . includes ( 'bitcoin' ) || id . includes ( 'btc' ) || id . includes ( 'lightning' ) || id . includes ( 'lnd' ) || id . includes ( 'electr' ) || id . includes ( 'fedimint' ) || id . includes ( 'cashu' ) || combined . includes ( 'wallet' ) ) return 'money'
if ( id . includes ( 'btcpay' ) || id . includes ( 'commerce' ) || id . includes ( 'shop' ) || id . includes ( 'pos' ) || combined . includes ( 'merchant' ) ) return 'commerce'
if ( id . includes ( 'cloud' ) || id . includes ( 'nextcloud' ) || id . includes ( 'storage' ) || id . includes ( 'file' ) || id . includes ( 'photo' ) || id . includes ( 'immich' ) || id . includes ( 'jellyfin' ) || id . includes ( 'media' ) || id . includes ( 'vault' ) || combined . includes ( 'password manager' ) ) return 'data'
if ( id . includes ( 'home-assistant' ) || id . includes ( 'homeassistant' ) || combined . includes ( 'home automation' ) ) return 'home'
if ( id . includes ( 'nostr' ) || combined . includes ( 'nostr relay' ) ) return 'nostr'
if ( id . includes ( 'vpn' ) || id . includes ( 'wireguard' ) || id . includes ( 'tailscale' ) || id . includes ( 'proxy' ) || id . includes ( 'dns' ) || id . includes ( 'tor' ) || combined . includes ( 'network' ) ) return 'networking'
if ( id . includes ( 'matrix' ) || id . includes ( 'mastodon' ) || id . includes ( 'chat' ) || id . includes ( 'social' ) || combined . includes ( 'messaging' ) ) return 'community'
return 'other'
}
const allApps = computed ( ( ) => {
const local : ( MarketplaceApp & { category : string ; source : string } ) [ ] = [ ]
const community = communityApps . value . map ( app => ( {
... app ,
category : categorizeCommunityApp ( app ) ,
source : 'community'
} ) )
const base = [ ... local , ... community ]
if ( nostrApps . value . length > 0 ) {
const existingIds = new Set ( base . map ( a => a . id ) )
const nostrMerged = nostrApps . value
. filter ( app => ! existingIds . has ( app . id ) )
. map ( app => ( { ... app , category : app . category || categorizeCommunityApp ( app ) , source : 'nostr' } ) )
return [ ... base , ... nostrMerged ]
}
return base
} )
const categoriesWithApps = computed ( ( ) => {
const apps = allApps . value
return categories . value . filter ( cat => {
if ( cat . id === 'all' ) return apps . length > 0
return apps . some ( app => app . category === cat . id )
} )
} )
const filteredApps = computed ( ( ) => {
let apps = allApps . value
if ( searchQuery . value ) {
const query = searchQuery . value . toLowerCase ( )
apps = apps . filter ( app =>
app . title ? . toLowerCase ( ) . includes ( query ) ||
( typeof app . description === 'string' && app . description . toLowerCase ( ) . includes ( query ) ) ||
( typeof app . description === 'object' && app . description ? . short ? . toLowerCase ( ) . includes ( query ) ) ||
app . id ? . toLowerCase ( ) . includes ( query ) ||
app . author ? . toLowerCase ( ) . includes ( query )
)
}
apps . sort ( ( a , b ) => {
const aInstalled = isInstalled ( a . id ) ? 1 : 0
const bInstalled = isInstalled ( b . id ) ? 1 : 0
return aInstalled - bInstalled
} )
return apps
} )
const installedCount = computed ( ( ) => {
return allApps . value . filter ( app => isInstalled ( app . id ) ) . length
} )
// Featured apps with rich descriptions
const featuredApps = computed < FeaturedApp [ ] > ( ( ) => {
const featured : { id : string ; desc : string ; tag : string } [ ] = [
{
id : 'bitcoin-knots' ,
desc : 'The foundation of sovereignty. Run a full Bitcoin node to validate every transaction yourself. No trusted third parties. No asking permission. Your node enforces the consensus rules that protect your wealth. Don\'t trust — verify.' ,
tag : 'FULL VALIDATION // ZERO TRUST'
} ,
{
id : 'lnd' ,
desc : 'Lightning-fast payments over the Lightning Network. Open channels, route transactions, and earn routing fees — all from your sovereign node. Instant settlement. Near-zero fees. The future of money, running on your hardware.' ,
tag : 'INSTANT SETTLEMENT // YOUR CHANNELS'
} ,
{
id : 'btcpay-server' ,
desc : 'Accept Bitcoin payments without intermediaries. No fees to payment processors. No KYC. No permission needed. Your commerce, your terms. Self-hosted payment infrastructure that makes you truly independent.' ,
tag : 'NO INTERMEDIARIES // NO KYC'
} ,
{
id : 'vaultwarden' ,
desc : 'Your passwords belong to you. Self-hosted password vault with full Bitwarden compatibility. Zero-knowledge encryption means even you can\'t see your passwords without your master key. No cloud required — your secrets, your server.' ,
tag : 'ZERO KNOWLEDGE // SELF-HOSTED'
} ,
]
return featured
. map ( f => {
const app = allApps . value . find ( a => a . id === f . id )
if ( ! app ) return null
2026-03-19 16:12:01 +00:00
return { ... app , featuredDescription : f . desc , privacyTag : f . tag } as FeaturedApp
feat: add Discover page — cypherpunk app store with sovereignty messaging
- New Discover.vue with hero banner, featured sovereignty stack apps,
principle cards, manifesto footer, and full app grid
- Featured apps (Bitcoin Knots, LND, BTCPay, Vaultwarden) with
expanded privacy/sovereignty descriptions
- Discover is first tab in categories bar on App Store pages
- Smart back navigation: detail pages return to Discover when navigated from there
- Category clicks from Discover navigate to Marketplace with category pre-selected
- Cypherpunk aesthetic: terminal tags, scanline overlays, gradient accents,
animated Bitcoin orange headings
- Global CSS classes: discover-hero, discover-terminal-tag, discover-featured-card,
discover-principle-card, discover-manifesto
- Route added: /dashboard/discover
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:14:12 +00:00
} )
. filter ( ( a ) : a is FeaturedApp => a !== null )
} )
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' ] ,
ollama : [ 'ollama' ] ,
}
function isInstalled ( appId : string ) : boolean {
if ( appId in installedPackages . value ) return true
const aliases = INSTALLED _ALIASES [ appId ]
return aliases ? aliases . some ( ( a ) => a in installedPackages . value ) : false
}
function getInstalledState ( appId : string ) : string | null {
const pkg = installedPackages . value [ appId ]
if ( pkg ) return pkg . state
const aliases = INSTALLED _ALIASES [ appId ]
if ( aliases ) {
for ( const a of aliases ) {
const aliasPkg = installedPackages . value [ a ]
if ( aliasPkg ) return aliasPkg . state
}
}
return null
}
function isStartingUp ( appId : string ) : boolean {
const state = getInstalledState ( appId )
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
}
function getAppTier ( appId : string ) : string {
const core = [ 'bitcoin-knots' , 'bitcoin' , 'lnd' , 'mempool' , 'btcpay-server' , 'dwn' , 'filebrowser' ]
const recommended = [ 'fedimint' , 'thunderhub' , 'vaultwarden' , 'uptime-kuma' , 'grafana' , 'searxng' , 'tailscale' , 'portainer' ]
if ( core . includes ( appId ) ) return 'core'
if ( recommended . includes ( appId ) ) return 'recommended'
return 'optional'
}
function launchInstalledApp ( app : MarketplaceApp ) {
appLauncher . openSession ( app . id )
}
// Curated app list
function getCuratedAppList ( ) {
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 : 'docker.io/bitcoinknots/bitcoin:v28.1' , repoUrl : 'https://github.com/bitcoinknots/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 : 'docker.io/btcpayserver/btcpayserver:1.13.5' , 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/lnd.svg' , author : 'Lightning Labs' , dockerImage : 'docker.io/lightninglabs/lnd:v0.17.4-beta' , repoUrl : 'https://github.com/lightningnetwork/lnd' } ,
{ id : 'thunderhub' , title : 'ThunderHub' , version : '0.13.31' , description : 'Lightning node management UI. Manage channels, payments, routing fees, and monitor your Lightning node.' , icon : '/assets/img/app-icons/thunderhub.svg' , author : 'Anthony Potdevin' , dockerImage : 'docker.io/apotdevin/thunderhub:v0.13.31' , repoUrl : 'https://github.com/apotdevin/thunderhub' } ,
{ id : 'mempool' , title : 'Mempool Explorer' , version : '2.5.0' , description : 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.' , icon : '/assets/img/app-icons/mempool.webp' , author : 'Mempool' , dockerImage : 'docker.io/mempool/frontend:v2.5.0' , repoUrl : 'https://github.com/mempool/mempool' } ,
{ id : 'homeassistant' , title : 'Home Assistant' , version : '2024.1' , description : 'Open-source home automation. Control smart home devices privately, on your own hardware.' , icon : '/assets/img/app-icons/homeassistant.png' , author : 'Home Assistant' , dockerImage : 'docker.io/homeassistant/home-assistant:2024.1' , repoUrl : 'https://github.com/home-assistant/core' } ,
{ id : 'grafana' , title : 'Grafana' , version : '10.2.0' , description : 'Analytics and monitoring platform. Dashboards for your node metrics and system health.' , icon : '/assets/img/app-icons/grafana.png' , author : 'Grafana Labs' , dockerImage : 'docker.io/grafana/grafana:10.2.0' , repoUrl : 'https://github.com/grafana/grafana' } ,
{ id : 'searxng' , title : 'SearXNG' , version : '2024.1.0' , description : 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.' , icon : '/assets/img/app-icons/searxng.png' , author : 'SearXNG' , dockerImage : 'docker.io/searxng/searxng:2024.11.17-e2554de75' , repoUrl : 'https://github.com/searxng/searxng' } ,
{ id : 'ollama' , title : 'Ollama' , version : '0.1.0' , description : 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.' , icon : '/assets/img/app-icons/ollama.png' , author : 'Ollama' , dockerImage : 'docker.io/ollama/ollama:0.5.4' , repoUrl : 'https://github.com/ollama/ollama' } ,
{ id : 'onlyoffice' , title : 'OnlyOffice' , version : '7.5.1' , description : 'Self-hosted office suite. Documents, spreadsheets, and presentations without the cloud.' , icon : '/assets/img/app-icons/onlyoffice.webp' , author : 'Ascensio System SIA' , dockerImage : 'docker.io/onlyoffice/documentserver:7.5.1' , repoUrl : 'https://github.com/ONLYOFFICE/DocumentServer' } ,
{ id : 'penpot' , title : 'Penpot' , version : '2.4' , description : 'Open-source design platform. Self-hosted alternative to Figma for design and prototyping.' , icon : '/assets/img/app-icons/penpot.webp' , author : 'Penpot' , dockerImage : 'docker.io/penpotapp/frontend:2.4' , repoUrl : 'https://github.com/penpot/penpot' } ,
{ id : 'nextcloud' , title : 'Nextcloud' , version : '28.0' , description : 'Your own private cloud. File sync, calendars, contacts — all on your hardware.' , icon : '/assets/img/app-icons/nextcloud.webp' , author : 'Nextcloud' , dockerImage : 'docker.io/library/nextcloud:28' , repoUrl : 'https://github.com/nextcloud/server' } ,
{ id : 'vaultwarden' , title : 'Vaultwarden' , version : '1.30.0' , description : 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.' , icon : '/assets/img/app-icons/vaultwarden.webp' , author : 'Vaultwarden' , dockerImage : 'docker.io/vaultwarden/server:1.30.0-alpine' , repoUrl : 'https://github.com/dani-garcia/vaultwarden' } ,
{ id : 'jellyfin' , title : 'Jellyfin' , version : '10.8.0' , description : 'Free media server. Stream your movies, music, and photos to any device.' , icon : '/assets/img/app-icons/jellyfin.webp' , author : 'Jellyfin' , dockerImage : 'docker.io/jellyfin/jellyfin:10.8.13' , repoUrl : 'https://github.com/jellyfin/jellyfin' } ,
{ id : 'photoprism' , title : 'PhotoPrism' , version : '240915' , description : 'AI-powered photo management. Organize photos with facial recognition, privately.' , icon : '/assets/img/app-icons/photoprism.svg' , author : 'PhotoPrism' , dockerImage : 'docker.io/photoprism/photoprism:240915' , repoUrl : 'https://github.com/photoprism/photoprism' } ,
{ id : 'immich' , title : 'Immich' , version : '1.90.0' , description : 'High-performance photo and video backup. Mobile-first with ML features.' , icon : '/assets/img/app-icons/immich.png' , author : 'Immich' , dockerImage : 'ghcr.io/immich-app/immich-server:release' , 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 on your server.' , icon : '/assets/img/app-icons/file-browser.webp' , author : 'File Browser' , dockerImage : 'docker.io/filebrowser/filebrowser:v2.27.0' , repoUrl : 'https://github.com/filebrowser/filebrowser' } ,
{ id : 'nginx-proxy-manager' , title : 'Nginx Proxy Manager' , version : '2.11.0' , description : 'Reverse proxy with SSL. Beautiful web interface for managing proxies.' , icon : '/assets/img/app-icons/nginx.svg' , author : 'Nginx Proxy Manager' , dockerImage : 'docker.io/jc21/nginx-proxy-manager:2.12.1' , repoUrl : 'https://github.com/NginxProxyManager/nginx-proxy-manager' } ,
{ id : 'portainer' , title : 'Portainer' , version : '2.19.0' , description : 'Container management UI. Manage your containerized services through the web.' , icon : '/assets/img/app-icons/portainer.webp' , author : 'Portainer' , dockerImage : 'docker.io/portainer/portainer-ce:2.19.4' , repoUrl : 'https://github.com/portainer/portainer' } ,
{ id : 'uptime-kuma' , title : 'Uptime Kuma' , version : '1.23.0' , description : 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.' , icon : '/assets/img/app-icons/uptime-kuma.webp' , author : 'Uptime Kuma' , dockerImage : 'docker.io/louislam/uptime-kuma:1' , repoUrl : 'https://github.com/louislam/uptime-kuma' } ,
{ id : 'tailscale' , title : 'Tailscale' , version : '1.78.0' , description : 'Zero-config VPN. Secure remote access with WireGuard mesh networking.' , icon : '/assets/img/app-icons/tailscale.webp' , author : 'Tailscale' , dockerImage : 'docker.io/tailscale/tailscale:stable' , repoUrl : 'https://github.com/tailscale/tailscale' } ,
{ id : 'fedimint' , title : 'Fedimint' , version : '0.10.0' , description : 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.' , icon : '/assets/img/app-icons/fedimint.png' , author : 'Fedimint' , dockerImage : 'docker.io/fedimint/fedimintd:v0.10.0' , repoUrl : 'https://github.com/fedimint/fedimint' } ,
{ id : 'indeedhub' , title : 'Indeehub' , version : '0.1.0' , description : 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.' , icon : '/assets/img/app-icons/indeedhub.png' , author : 'Indeehub Team' , dockerImage : 'localhost/indeedhub:latest' , repoUrl : 'https://github.com/indeedhub/indeedhub' } ,
{ id : 'dwn' , title : 'Decentralized Web Node' , version : '0.4.0' , description : 'Own your data with DID-based access control. Sync across devices, sovereign.' , icon : '/assets/img/app-icons/dwn.svg' , author : 'TBD' , dockerImage : 'ghcr.io/tbd54566975/dwn-server:main' , repoUrl : 'https://github.com/TBD54566975/dwn-server' } ,
{ id : 'nostrudel' , title : 'noStrudel' , version : '0.40.0' , category : 'nostr' , description : 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.' , icon : '/assets/img/app-icons/nostrudel.svg' , author : 'hzrd149' , dockerImage : '' , repoUrl : 'https://github.com/hzrd149/nostrudel' , webUrl : 'https://nostrudel.ninja' } ,
{ id : 'nostr-rs-relay' , title : 'Nostr Relay' , version : '0.9.0' , category : 'nostr' , description : 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.' , icon : '/assets/img/app-icons/nostr-rs-relay.svg' , author : 'scsiblade' , dockerImage : 'docker.io/scsiblade/nostr-rs-relay:0.9.0' , repoUrl : 'https://sr.ht/~gheartsfield/nostr-rs-relay/' } ,
{ id : 'botfights' , title : 'BotFights' , version : '1.0.0' , description : 'AI bot arena — build, train, and battle autonomous agents in strategy tournaments.' , icon : '/assets/img/app-icons/botfights.svg' , author : 'BotFights' , dockerImage : '' , repoUrl : 'https://botfights.net' , webUrl : 'https://botfights.net' } ,
{ id : 'nwnn' , title : 'Next Web News Network' , version : '1.0.0' , category : 'l484' , description : 'Decentralized news aggregator. Community-curated Bitcoin and sovereignty content.' , icon : '/assets/img/app-icons/nwnn.png' , author : 'L484' , dockerImage : '' , repoUrl : 'https://nwnn.l484.com' , webUrl : 'https://nwnn.l484.com' } ,
{ id : '484-kitchen' , title : '484 Kitchen' , version : '1.0.0' , category : 'l484' , description : 'K484 application platform for the L484 network.' , icon : '/assets/img/app-icons/484-kitchen.png' , author : 'L484' , dockerImage : '' , repoUrl : 'https://484.kitchen' , webUrl : 'https://484.kitchen' } ,
{ id : 'call-the-operator' , title : 'Call the Operator' , version : '1.0.0' , category : 'l484' , description : 'Escape the Matrix — explore decentralized alternatives and reclaim sovereignty.' , icon : '/assets/img/app-icons/call-the-operator.png' , author : 'TX1138' , dockerImage : '' , repoUrl : 'https://cta.tx1138.com' , webUrl : 'https://cta.tx1138.com' } ,
{ id : 'arch-presentation' , title : 'Arch Presentation' , version : '1.0.0' , category : 'l484' , description : 'The Future of Decentralized Infrastructure — interactive Archipelago presentation.' , icon : '/assets/img/app-icons/arch-presentation.png' , author : 'L484' , dockerImage : '' , repoUrl : 'https://present.l484.com' , webUrl : 'https://present.l484.com' } ,
{ id : 'syntropy-institute' , title : 'Syntropy Institute' , version : '1.0.0' , category : 'l484' , description : 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency, and concierge protocols.' , icon : '/assets/img/app-icons/syntropy-institute.png' , author : 'Syntropy Institute' , dockerImage : '' , repoUrl : 'https://syntropy.institute' , webUrl : 'https://syntropy.institute' } ,
{ id : 't-zero' , title : 'T-0' , version : '1.0.0' , category : 'l484' , description : 'Documentary series exploring decentralization and the mavericks building the ungovernable future.' , icon : '/assets/img/app-icons/t-zero.png' , author : 'T-0' , dockerImage : '' , repoUrl : 'https://teeminuszero.net' , webUrl : 'https://teeminuszero.net' } ,
]
}
onMounted ( ( ) => {
discoverAnimationDone = true
if ( communityApps . value . length === 0 && ! loadingCommunity . value ) {
loadCommunityMarketplace ( )
}
} )
async function loadCommunityMarketplace ( ) {
loadingCommunity . value = true
communityError . value = ''
if ( import . meta . env . DEV ) console . log ( 'Loading Docker-based app marketplace' )
communityApps . value = getCuratedAppList ( )
loadingCommunity . value = false
}
function viewAppDetails ( app : MarketplaceApp ) {
try {
if ( isInstalled ( app . id ) ) {
router . push ( { name : 'app-details' , params : { id : app . id } , query : { from : 'discover' } } )
} else {
setCurrentApp ( app )
router . push ( { name : 'marketplace-app-detail' , params : { id : app . id } , query : { from : 'discover' } } )
}
} catch ( e ) {
if ( import . meta . env . DEV ) console . error ( '[Discover] Navigation error:' , e )
}
}
// Timer management
const activeTimers : ReturnType < typeof setTimeout > [ ] = [ ]
const activeIntervals : ReturnType < typeof setInterval > [ ] = [ ]
function trackTimeout ( fn : ( ) => void , ms : number ) {
const id = setTimeout ( ( ) => {
const idx = activeTimers . indexOf ( id )
if ( idx !== - 1 ) activeTimers . splice ( idx , 1 )
fn ( )
} , ms )
activeTimers . push ( id )
return id
}
function trackInterval ( fn : ( ) => void , ms : number ) {
const id = setInterval ( fn , ms )
activeIntervals . push ( id )
return id
}
function clearTrackedInterval ( id : ReturnType < typeof setInterval > ) {
clearInterval ( id )
const idx = activeIntervals . indexOf ( id )
if ( idx !== - 1 ) activeIntervals . splice ( idx , 1 )
}
onBeforeUnmount ( ( ) => {
for ( const t of activeTimers ) clearTimeout ( t )
activeTimers . length = 0
for ( const i of activeIntervals ) clearInterval ( i )
activeIntervals . length = 0
} )
function startInstallPolling ( appId : string , statusMessage : string ) {
const interval = trackInterval ( ( ) => {
const current = installingApps . value . get ( appId )
if ( ! current ) { clearTrackedInterval ( interval ) ; return }
const newAttempt = current . attempt + 1
installingApps . value . set ( appId , { ... current , attempt : newAttempt , progress : Math . min ( 60 + ( newAttempt * 0.5 ) , 95 ) , message : statusMessage } )
if ( isInstalled ( appId ) ) {
clearTrackedInterval ( interval )
installingApps . value . set ( appId , { ... current , status : 'complete' , progress : 100 , message : 'Installation complete!' } )
trackTimeout ( ( ) => { installingApps . value . delete ( appId ) } , 2000 )
} else if ( newAttempt >= maxAttempts . value ) {
clearTrackedInterval ( interval )
installingApps . value . set ( appId , { ... current , status : 'error' , progress : 0 , message : 'Installation timeout' } )
trackTimeout ( ( ) => { installingApps . value . delete ( appId ) } , 5000 )
}
} , 1000 )
}
async function installApp ( app : MarketplaceApp ) {
if ( installingApps . value . has ( app . id ) || isInstalled ( app . id ) ) return
installingApps . value . set ( app . id , { id : app . id , title : app . title ? ? app . id , status : 'downloading' , progress : 10 , message : 'Preparing installation...' , attempt : 0 } )
try {
const installUrl = app . url || app . manifestUrl || app . s9pkUrl
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'downloading' , progress : 30 , message : 'Downloading package...' } )
await rpcClient . call ( { method : 'package.install' , params : { id : app . id , url : installUrl , version : app . version } } )
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'installing' , progress : 60 , message : 'Installing package...' } )
startInstallPolling ( app . id , 'Starting application...' )
} catch ( err ) {
if ( import . meta . env . DEV ) console . error ( 'Installation failed:' , err )
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'error' , progress : 0 , message : ` Failed: ${ err } ` } )
trackTimeout ( ( ) => { installingApps . value . delete ( app . id ) } , 5000 )
}
}
async function installCommunityApp ( app : MarketplaceApp ) {
if ( installingApps . value . has ( app . id ) || isInstalled ( app . id ) || ! app . dockerImage ) return
installingApps . value . set ( app . id , { id : app . id , title : app . title ? ? app . id , status : 'downloading' , progress : 10 , message : 'Pulling Docker image...' , attempt : 0 } )
try {
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'downloading' , progress : 20 , message : 'Downloading container image...' } )
await rpcClient . call ( { method : 'package.install' , params : { id : app . id , dockerImage : app . dockerImage , version : app . version } , timeout : 180000 } )
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'installing' , progress : 60 , message : 'Starting container...' } )
startInstallPolling ( app . id , 'Initializing application...' )
} catch ( err ) {
if ( import . meta . env . DEV ) console . error ( '[Discover] Installation failed:' , err )
installingApps . value . set ( app . id , { ... installingApps . value . get ( app . id ) ! , status : 'error' , progress : 0 , message : ` Failed: ${ err } ` } )
trackTimeout ( ( ) => { installingApps . value . delete ( app . id ) } , 5000 )
}
}
function handleImageError ( event : Event ) {
const img = event . target as HTMLImageElement
img . src = '/assets/img/logo-archipelago.svg'
}
< / script >
< style scoped >
. line - clamp - 3 {
display : - webkit - box ;
- webkit - line - clamp : 3 ;
- webkit - box - orient : vertical ;
overflow : hidden ;
}
. discover - shimmer - bg {
position : absolute ;
inset : 0 ;
background : linear - gradient ( 90 deg , 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 2 s ease - in - out infinite ;
border - radius : inherit ;
}
@ keyframes shimmer {
0 % { background - position : 200 % 0 ; }
100 % { background - position : - 200 % 0 ; }
}
< / style >