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>
This commit is contained in:
parent
aabeb2e679
commit
5e19a80f9d
@ -98,6 +98,11 @@ const router = createRouter({
|
||||
name: 'lightning-channels',
|
||||
component: () => import('../views/apps/LightningChannels.vue'),
|
||||
},
|
||||
{
|
||||
path: 'discover',
|
||||
name: 'discover',
|
||||
component: () => import('../views/Discover.vue'),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
name: 'marketplace',
|
||||
@ -134,6 +139,11 @@ const router = createRouter({
|
||||
name: 'monitoring',
|
||||
component: () => import('../views/Monitoring.vue'),
|
||||
},
|
||||
{
|
||||
path: 'fleet',
|
||||
name: 'fleet',
|
||||
component: () => import('../views/Fleet.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/federation',
|
||||
name: 'federation',
|
||||
|
||||
@ -1904,3 +1904,260 @@ html:has(body.video-background-active)::before {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Fleet dashboard */
|
||||
.fleet-node-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fleet-node-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.fleet-node-card-selected {
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
background: rgba(251, 146, 60, 0.06);
|
||||
}
|
||||
|
||||
.fleet-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fleet-dot-online {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 6px rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
|
||||
.fleet-dot-offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.fleet-version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.fleet-metric-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fleet-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fleet-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.fleet-node-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: 500;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fleet-sort-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fleet-sort-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.fleet-sort-btn-active {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.fleet-text-warn {
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.fleet-text-danger {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
/* ── Discover Page (App Store) ──── */
|
||||
|
||||
.discover-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.discover-hero {
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.75) 0%, rgba(251, 146, 60, 0.06) 100%);
|
||||
border-color: rgba(251, 146, 60, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-hero-scanline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
180deg,
|
||||
transparent 0px,
|
||||
transparent 3px,
|
||||
rgba(255, 255, 255, 0.015) 3px,
|
||||
rgba(255, 255, 255, 0.015) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.discover-hero-accent {
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b, #fb923c);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: discover-gradient-shift 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes discover-gradient-shift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.discover-terminal-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
color: rgba(251, 146, 60, 0.8);
|
||||
border: 1px solid rgba(251, 146, 60, 0.2);
|
||||
}
|
||||
|
||||
.discover-stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.discover-featured-card {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.discover-featured-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(251, 146, 60, 0.25);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
0 0 40px rgba(251, 146, 60, 0.06);
|
||||
}
|
||||
|
||||
.discover-installed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.discover-principle-card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: border-color 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.discover-principle-card:hover {
|
||||
border-color: rgba(251, 146, 60, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.discover-app-card {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.discover-app-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.discover-manifesto {
|
||||
border-color: rgba(251, 146, 60, 0.1);
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(251, 146, 60, 0.03) 100%);
|
||||
}
|
||||
|
||||
.fleet-matrix-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.fleet-matrix-header-cell {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fleet-matrix-cell {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@ -644,12 +644,12 @@ useModalKeyboard(
|
||||
|
||||
// Determine back button text based on where user came from
|
||||
const backButtonText = computed(() => {
|
||||
// Check if we came from marketplace via query parameter
|
||||
if (route.query.from === 'discover') {
|
||||
return 'Back to Discover'
|
||||
}
|
||||
if (route.query.from === 'marketplace') {
|
||||
return t('appDetails.backToStore')
|
||||
}
|
||||
|
||||
// Default to My Apps
|
||||
return t('appDetails.backToApps')
|
||||
})
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
@click="appLauncher.closePanel()"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
@ -201,7 +201,7 @@
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') }"
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
|
||||
>App Store</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps?tab=services"
|
||||
@ -496,7 +496,7 @@ const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
||||
const showAppsTabs = computed(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (window.innerWidth >= 768) return false
|
||||
return route.path.includes('/apps') || route.path.includes('/marketplace')
|
||||
return route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover')
|
||||
})
|
||||
|
||||
// Show persistent tabs for Network/Cloud on mobile
|
||||
|
||||
967
neode-ui/src/views/Discover.vue
Normal file
967
neode-ui/src/views/Discover.vue
Normal file
@ -0,0 +1,967 @@
|
||||
<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>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn mode-switcher-btn-active"
|
||||
>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>
|
||||
|
||||
<!-- Installation Progress Banners -->
|
||||
<div v-if="installingApps.size > 0" aria-live="polite" class="mb-6 space-y-3">
|
||||
<div
|
||||
v-for="[appId, progress] in installingApps"
|
||||
:key="appId"
|
||||
class="glass-card p-4 border-l-4"
|
||||
:class="{
|
||||
'border-blue-500': progress.status === 'downloading' || progress.status === 'installing',
|
||||
'border-orange-500': progress.status === 'starting',
|
||||
'border-green-500': progress.status === 'complete',
|
||||
'border-red-500': progress.status === 'error'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
v-if="progress.status !== 'complete' && progress.status !== 'error'"
|
||||
class="animate-spin h-5 w-5 text-blue-400" aria-hidden="true"
|
||||
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>
|
||||
<svg v-else-if="progress.status === 'complete'" class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-white font-medium">{{ progress.title }}</p>
|
||||
<p class="text-white/70 text-sm">{{ progress.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-white/60 text-sm">{{ progress.progress }}%</div>
|
||||
</div>
|
||||
<div class="w-full bg-white/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="{
|
||||
'bg-gradient-to-r from-blue-500 to-blue-400': progress.status === 'downloading' || progress.status === 'installing',
|
||||
'bg-gradient-to-r from-orange-500 to-orange-400': progress.status === 'starting',
|
||||
'bg-gradient-to-r from-green-500 to-green-400': progress.status === 'complete',
|
||||
'bg-gradient-to-r from-red-500 to-red-400': progress.status === 'error'
|
||||
}"
|
||||
:style="{ width: `${progress.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 }} · 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 — not
|
||||
corporations — 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'
|
||||
: 'glass-card text-white/80 hover:bg-white/10'
|
||||
]"
|
||||
>
|
||||
<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 currentCategoryName = computed(() => {
|
||||
return categories.value.find(c => c.id === selectedCategory.value)?.name || 'All'
|
||||
})
|
||||
|
||||
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
|
||||
return { ...app, featuredDescription: f.desc, privacyTag: f.tag }
|
||||
})
|
||||
.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(90deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@ -82,6 +82,10 @@
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
>Discover</RouterLink>
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@ -131,7 +135,8 @@
|
||||
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card card-stagger p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
|
||||
class="glass-card p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
|
||||
:class="{ 'card-stagger': showStagger }"
|
||||
:style="{ '--stagger-index': index }"
|
||||
@click="viewAppDetails(app)"
|
||||
@keydown.enter="viewAppDetails(app)"
|
||||
@ -365,9 +370,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
@ -378,13 +387,16 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: number; trustTier?: string; relayCount?: number }
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showStagger = !marketplaceAnimationDone
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
// Category state
|
||||
const selectedCategory = ref('all')
|
||||
// Category state — read initial value from query param (set by Discover page navigation)
|
||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
@ -721,6 +733,7 @@ function launchInstalledApp(app: MarketplaceApp) {
|
||||
|
||||
// Load community marketplace on mount
|
||||
onMounted(() => {
|
||||
marketplaceAnimationDone = true
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
|
||||
@ -506,7 +506,11 @@ function handleImageError(e: Event) {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
} else {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function goToInstalledApp() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user