feat: botfights, discover, mobile gamepad, content handler, package config updates
Miscellaneous improvements: botfights manifest, discover page curated apps, mobile gamepad enhancements, content HTTP handler, package install config updates, health monitor tweaks, shared content UI, container specs and image version updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
24f122f35a
commit
bb14490fb7
@@ -102,6 +102,7 @@ const emit = defineEmits<{
|
||||
navigate: [path: string]
|
||||
delete: [path: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
preview: [path: string]
|
||||
}>()
|
||||
|
||||
const cloudStore = useCloudStore()
|
||||
@@ -109,7 +110,7 @@ const imgFailed = ref(false)
|
||||
|
||||
const ext = computed(() => props.item.extension)
|
||||
const isDir = computed(() => props.item.isDir)
|
||||
const { isImage, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value || imgFailed.value) return null
|
||||
@@ -121,6 +122,8 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
function handleClick() {
|
||||
if (props.item.isDir) {
|
||||
emit('navigate', props.item.path)
|
||||
} else if (isImage.value || isVideo.value) {
|
||||
emit('preview', props.item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
@delete="$emit('delete', $event)"
|
||||
@play="(path, name) => $emit('play', path, name)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
@preview="$emit('preview', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +54,7 @@
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
|
||||
@preview="$emit('preview', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,5 +78,6 @@ defineEmits<{
|
||||
delete: [path: string]
|
||||
play: [path: string, name: string]
|
||||
share: [path: string, name: string, isDir: boolean]
|
||||
preview: [path: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1894,6 +1894,113 @@ html:has(body.video-background-active)::before {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Media Lightbox ── */
|
||||
.lightbox-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
.lightbox-counter {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lightbox-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 4010;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.lightbox-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.lightbox-nav-prev { left: 1rem; }
|
||||
.lightbox-nav-next { right: 1rem; }
|
||||
.lightbox-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: calc(100vw - 8rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
}
|
||||
.lightbox-media {
|
||||
max-width: calc(100vw - 8rem);
|
||||
max-height: calc(100vh - 6rem);
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.lightbox-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.lightbox-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.lightbox-filename {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 4010;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 0.375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.lightbox-nav-prev { left: 0.25rem; }
|
||||
.lightbox-nav-next { right: 0.25rem; }
|
||||
.lightbox-content {
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
}
|
||||
.lightbox-media {
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Share action button highlight */
|
||||
.cloud-file-action-share:hover {
|
||||
background: rgba(251, 146, 60, 0.2);
|
||||
@@ -2302,6 +2409,42 @@ html:has(body.video-background-active)::before {
|
||||
border: 1px solid rgba(251, 146, 60, 0.2);
|
||||
}
|
||||
|
||||
/* Featured App Banner */
|
||||
.featured-banner {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(251, 146, 60, 0.15);
|
||||
}
|
||||
.featured-banner-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.featured-banner:hover .featured-banner-img {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.featured-banner-overlay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 320px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.5) 40%, rgba(0,0,0,0.1) 100%);
|
||||
border-radius: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.featured-banner { min-height: 240px; }
|
||||
.featured-banner-overlay { padding: 1.5rem; min-height: 240px; }
|
||||
}
|
||||
|
||||
.discover-stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
/>
|
||||
|
||||
<!-- Mobile: gamepad for botfights, browser bar for everything else -->
|
||||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||||
<MobileGamepad
|
||||
v-if="isMobile && appId === 'botfights'"
|
||||
:iframe-ref="iframeRef ?? null"
|
||||
:player="1"
|
||||
@refresh="refresh"
|
||||
@openBrowser="openNewTab"
|
||||
@close="closeSession"
|
||||
/>
|
||||
<div v-else class="md:hidden app-session-mobile-bar">
|
||||
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero + Featured (only when no search) -->
|
||||
<!-- Hero + Featured + Banner (only when no search) -->
|
||||
<template v-if="!searchQuery">
|
||||
<DiscoverHero
|
||||
:total-apps="allApps.length"
|
||||
@@ -65,10 +65,49 @@
|
||||
@install="handleInstall"
|
||||
/>
|
||||
|
||||
<!-- Featured App Banner (from catalog or hardcoded) -->
|
||||
<div
|
||||
v-if="featuredBanner"
|
||||
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
|
||||
>
|
||||
<img
|
||||
:src="featuredBanner.banner"
|
||||
:alt="featuredBanner.headline"
|
||||
class="featured-banner-img"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="featured-banner-overlay">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="discover-terminal-tag">featured</span>
|
||||
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
|
||||
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
|
||||
@click.stop="launchInstalledApp(featuredBannerApp)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
|
||||
>Launch</button>
|
||||
<button
|
||||
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
|
||||
@click.stop="handleInstall(featuredBannerApp)"
|
||||
:disabled="installingApps.has(featuredBannerApp.id)"
|
||||
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
|
||||
<span v-else>Install</span>
|
||||
</button>
|
||||
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} v{{ featuredBannerApp?.version }}</span>
|
||||
</div>
|
||||
</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>
|
||||
<h2 class="text-xl font-bold text-white">Available to Install</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
|
||||
</div>
|
||||
@@ -150,7 +189,7 @@ import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import FilterModal from './discover/FilterModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
@@ -297,11 +336,8 @@ const filteredApps = computed(() => {
|
||||
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
|
||||
})
|
||||
// Hide installed apps and web-only links (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && app.dockerImage)
|
||||
return apps
|
||||
})
|
||||
|
||||
@@ -309,6 +345,21 @@ const installedCount = computed(() => {
|
||||
return allApps.value.filter(app => isInstalled(app.id)).length
|
||||
})
|
||||
|
||||
// Featured banner — from catalog.json or first FEATURED_DEFINITIONS entry with banner
|
||||
const featuredBanner = computed(() => {
|
||||
if (catalogFeatured.value) return catalogFeatured.value
|
||||
const first = FEATURED_DEFINITIONS.find(f => f.banner)
|
||||
if (!first) return null
|
||||
const app = allApps.value.find(a => a.id === first.id)
|
||||
if (!app) return null
|
||||
return { id: first.id, banner: first.banner!, headline: app.title ?? first.id, description: first.desc, tag: first.tag }
|
||||
})
|
||||
|
||||
const featuredBannerApp = computed(() => {
|
||||
if (!featuredBanner.value) return null
|
||||
return allApps.value.find(a => a.id === featuredBanner.value!.id) ?? null
|
||||
})
|
||||
|
||||
const featuredApps = computed<FeaturedApp[]>(() => {
|
||||
return FEATURED_DEFINITIONS
|
||||
.map(f => {
|
||||
@@ -473,11 +524,21 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
if (import.meta.env.DEV) console.log('Loading Docker-based app marketplace')
|
||||
communityApps.value = getCuratedAppList()
|
||||
// Try dynamic catalog first, fall back to hardcoded
|
||||
const catalog = await fetchAppCatalog()
|
||||
if (catalog) {
|
||||
communityApps.value = catalog.apps
|
||||
catalogFeatured.value = catalog.featured
|
||||
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
|
||||
} else {
|
||||
communityApps.value = getCuratedAppList()
|
||||
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
|
||||
}
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -271,8 +271,8 @@ const filteredApps = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// Hide installed and installing apps from marketplace — they belong in My Apps
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id))
|
||||
// Hide installed, installing, and web-only apps (no dockerImage = not installable)
|
||||
apps = apps.filter(app => !isInstalled(app.id) && !installingApps.has(app.id) && app.dockerImage)
|
||||
|
||||
return apps
|
||||
})
|
||||
|
||||
@@ -43,18 +43,37 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: START / SELECT -->
|
||||
<!-- Center: START / SELECT + utility buttons -->
|
||||
<div class="gamepad-meta">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
<div class="meta-row">
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Escape')"
|
||||
aria-label="Select"
|
||||
>SEL</button>
|
||||
<button
|
||||
class="meta-btn"
|
||||
@touchstart.prevent="tap('Enter')"
|
||||
aria-label="Start"
|
||||
>START</button>
|
||||
</div>
|
||||
<div class="meta-utility">
|
||||
<button class="util-btn" aria-label="Refresh" @click="$emit('refresh')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Open in browser" @click="$emit('openBrowser')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="util-btn" aria-label="Close" @click="$emit('close')">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (right side) -->
|
||||
@@ -83,6 +102,12 @@ const props = defineProps<{
|
||||
player?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
openBrowser: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function send(key: string, action: 'down' | 'up') {
|
||||
props.iframeRef?.contentWindow?.postMessage(
|
||||
{ type: 'arcade-input', key, player: props.player ?? 1, action },
|
||||
@@ -152,10 +177,39 @@ function tap(key: string) { send(key, 'down'); setTimeout(() => send(key, 'up'),
|
||||
.gamepad-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-utility {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.util-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.util-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.meta-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -2,6 +2,47 @@ import type { MarketplaceApp } from './types'
|
||||
|
||||
const R = 'git.tx1138.com/lfg2025'
|
||||
|
||||
// ---------- Dynamic catalog from registry ----------
|
||||
export interface CatalogFeatured {
|
||||
id: string
|
||||
banner: string
|
||||
headline: string
|
||||
description: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface AppCatalog {
|
||||
version: number
|
||||
registry: string
|
||||
featured: CatalogFeatured
|
||||
apps: MarketplaceApp[]
|
||||
}
|
||||
|
||||
let cachedCatalog: AppCatalog | null = null
|
||||
|
||||
/** Fetch catalog.json (served by nginx, can be updated independently of the build).
|
||||
* Returns null if fetch fails — caller should fall back to hardcoded list. */
|
||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
if (cachedCatalog) return cachedCatalog
|
||||
try {
|
||||
const res = await fetch('/catalog.json', { signal: AbortSignal.timeout(5000) })
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as AppCatalog
|
||||
// Expand short docker image refs to full registry paths
|
||||
const registry = data.registry || R
|
||||
for (const app of data.apps) {
|
||||
if (app.dockerImage && !app.dockerImage.includes('/')) {
|
||||
app.dockerImage = `${registry}/${app.dockerImage}`
|
||||
}
|
||||
}
|
||||
cachedCatalog = data
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Hardcoded fallback (used when catalog.json is unavailable) ----------
|
||||
export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
@@ -27,13 +68,13 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ 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: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ 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: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' },
|
||||
{ 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: 'git.tx1138.com/lfg2025/indeedhub:latest', repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, 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: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
|
||||
{ id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.7`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' },
|
||||
{ id: 'fips', title: 'FIPS', version: '0.1.0', category: 'networking', description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.', icon: '/assets/img/app-icons/fips.svg', author: 'Jim Corgan', dockerImage: `${R}/fips:v0.1.0`, repoUrl: 'https://github.com/jmcorgan/fips' },
|
||||
{ id: 'routstr', title: 'Routstr', version: '0.4.3', category: 'community', description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.', icon: '/assets/img/app-icons/routstr.svg', author: 'Routstr', dockerImage: `${R}/routstr:v0.4.3`, repoUrl: 'https://github.com/routstr/routstr-core' },
|
||||
{ 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: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.0.0`, repoUrl: 'https://botfights.net' },
|
||||
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: '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' },
|
||||
@@ -43,49 +84,68 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
]
|
||||
}
|
||||
|
||||
// Only PRIMARY containers trigger "installed" status.
|
||||
// Supporting containers (DBs, caches, workers) do NOT — having only a DB
|
||||
// without the main app should not mark the app as installed in the UI.
|
||||
export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
btcpay: ['btcpay-server'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
electrumx: ['electrumx', 'archy-electrs-ui'],
|
||||
electrumx: ['electrumx'],
|
||||
grafana: ['grafana'],
|
||||
jellyfin: ['jellyfin'],
|
||||
vaultwarden: ['vaultwarden'],
|
||||
searxng: ['searxng'],
|
||||
homeassistant: ['homeassistant'],
|
||||
photoprism: ['photoprism'],
|
||||
lnd: ['lnd', 'archy-lnd-ui'],
|
||||
lnd: ['lnd'],
|
||||
filebrowser: ['filebrowser'],
|
||||
tailscale: ['tailscale'],
|
||||
ollama: ['ollama'],
|
||||
indeedhub: ['indeedhub'],
|
||||
'nostr-vpn': ['nostr-vpn'],
|
||||
fips: ['fips'],
|
||||
routstr: ['routstr'],
|
||||
botfights: ['botfights'],
|
||||
}
|
||||
|
||||
export const FEATURED_DEFINITIONS = [
|
||||
// Featured apps shown at the top of the App Store.
|
||||
// The first entry with a `banner` is displayed as a full-width hero banner.
|
||||
// To change the featured app, move the desired entry to position 0 and set its `banner`.
|
||||
export const FEATURED_DEFINITIONS: {
|
||||
id: string
|
||||
desc: string
|
||||
tag: string
|
||||
banner?: string // path to banner image (shown as full-width hero)
|
||||
}[] = [
|
||||
{
|
||||
id: 'indeedhub',
|
||||
desc: 'Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node. No accounts, no subscriptions. Sign in with Nostr.',
|
||||
tag: 'NOSTR IDENTITY // YOUR NODE',
|
||||
banner: '/assets/img/featured/indeedhub-banner.jpg',
|
||||
},
|
||||
{
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
tag: 'ZERO KNOWLEDGE // SELF-HOSTED',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
|
||||
export type FeaturedApp = MarketplaceApp & {
|
||||
featuredDescription: string
|
||||
privacyTag: string
|
||||
bannerImage?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
|
||||
@@ -474,7 +474,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.',
|
||||
icon: '/assets/img/app-icons/botfights.svg',
|
||||
author: 'BotFights',
|
||||
dockerImage: `${REGISTRY}/botfights:1.0.0`,
|
||||
dockerImage: `${REGISTRY}/botfights:1.1.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://botfights.net',
|
||||
},
|
||||
|
||||
@@ -213,12 +213,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMediaType(pItem.mime_type)"
|
||||
v-if="isMediaType(pItem.mime_type) && getItemPrice(pItem.access) === 0"
|
||||
@click="streamPeerContent(pItem)"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||
>
|
||||
{{ t('web5.stream') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="getItemPrice(pItem.access) > 0"
|
||||
@click="purchaseAndDownload(pItem)"
|
||||
:disabled="purchasingId === pItem.id"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
|
||||
>
|
||||
<template v-if="purchasingId === pItem.id">
|
||||
<div class="w-3 h-3 border-2 border-orange-400/30 border-t-orange-400 rounded-full animate-spin"></div>
|
||||
Paying...
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Buy {{ getItemPrice(pItem.access) }} sats
|
||||
</template>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="downloadPeerContent(pItem)"
|
||||
@@ -370,6 +387,9 @@ const browsingPeerContent = ref(false)
|
||||
const browsePeerError = ref('')
|
||||
const peerContentItems = ref<PeerContentItem[]>([])
|
||||
|
||||
// Purchase flow
|
||||
const purchasingId = ref<string | null>(null)
|
||||
|
||||
// Streaming player
|
||||
const streamingItem = ref<PeerContentItem | null>(null)
|
||||
const streamUrl = ref('')
|
||||
@@ -513,6 +533,53 @@ function downloadPeerContent(item: PeerContentItem) {
|
||||
safeClipboardWrite(url)
|
||||
}
|
||||
|
||||
async function purchaseAndDownload(item: PeerContentItem) {
|
||||
if (!browsePeerOnion.value || purchasingId.value) return
|
||||
const price = getItemPrice(item.access)
|
||||
if (price <= 0) return
|
||||
|
||||
purchasingId.value = item.id
|
||||
try {
|
||||
// Check balance first
|
||||
try {
|
||||
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
||||
const balance = balRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (result?.data) {
|
||||
const blob = new Blob(
|
||||
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
|
||||
{ type: item.mime_type },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.filename.split('/').pop() || item.filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
emit('toast', `Downloaded for ${price} sats`)
|
||||
} else {
|
||||
emit('toast', 'Purchase failed — no data received')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
emit('toast', e instanceof Error ? e.message : 'Purchase failed')
|
||||
} finally {
|
||||
purchasingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
if (audioPlayerRef.value) {
|
||||
audioPlayerRef.value.pause()
|
||||
|
||||
Reference in New Issue
Block a user