refactor: split Marketplace, Server, Home, AppDetails views; minor frontend quality fixes

- F29-F32: Split 4 views (Marketplace 1293→505, Server 1132→486, Home 1059→394, AppDetails 1036→386)
- F20: Add aria-current="page" to Dashboard nav links
- F21: Add 150ms search debounce in Marketplace and Apps views
- F22: Reduce backdrop-filter blur to 8px on mobile for GPU performance
- F23: Track and clear WebSocket connect check interval in all paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-21 03:01:38 +00:00
co-authored by Claude Opus 4.6
parent ea1b1f826b
commit bfbaa36709
20 changed files with 2713 additions and 3206 deletions
+17 -7
View File
@@ -29,6 +29,7 @@ export class WebSocketClient {
private _state: ConnectionState = 'disconnected'
private isReconnecting = false
private parseErrorCount = 0
private connectCheckInterval: ReturnType<typeof setInterval> | null = null
constructor(url: string = '/ws/db') {
this.url = url
@@ -81,25 +82,26 @@ export class WebSocketClient {
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
const checkInterval = setInterval(() => {
this.clearConnectCheck()
this.connectCheckInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
clearInterval(checkInterval)
this.clearConnectCheck()
resolve()
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
clearInterval(checkInterval)
this.clearConnectCheck()
// Connection failed or closing, will be handled by onclose
reject(new Error('Connection closed during connect'))
}
} else {
clearInterval(checkInterval)
this.clearConnectCheck()
reject(new Error('WebSocket was cleared'))
}
}, 100)
// Timeout after 5 seconds
setTimeout(() => {
clearInterval(checkInterval)
this.clearConnectCheck()
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Connection timeout'))
}
@@ -285,6 +287,13 @@ export class WebSocketClient {
this.connectionStateCallbacks.forEach((callback) => callback(state))
}
private clearConnectCheck(): void {
if (this.connectCheckInterval) {
clearInterval(this.connectCheckInterval)
this.connectCheckInterval = null
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
@@ -334,7 +343,8 @@ export class WebSocketClient {
this.reconnectAttempts = 0
this.setConnectionState('disconnecting')
this.stopHeartbeat()
this.clearConnectCheck()
// Clear reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
+8
View File
@@ -2161,3 +2161,11 @@ html:has(body.video-background-active)::before {
white-space: nowrap;
}
/* Mobile GPU optimization — reduce blur radius for cheaper compositing */
@media (max-width: 768px) {
.glass-card, .glass-button {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
}
+67 -717
View File
@@ -22,409 +22,40 @@
</Teleport>
<div v-if="pkg">
<!-- Compact Hero Section -->
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<!-- App Icon -->
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<!-- App Info (grows to fill space) -->
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
v-if="packageKey === 'lnd'"
@click="router.push('/dashboard/apps/lnd/channels')"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
>
<svg class="w-4 h-4" 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>
{{ t('appDetails.channels') }}
</button>
<button
v-if="canLaunch"
@click="launchApp"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
@click="restartApp"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.restart') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="stopApp"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="uninstallApp"
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ t('common.uninstall') }}
</button>
</template>
</div>
</div>
<!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden">
<!-- Header: Icon + Info + Delete -->
<div class="flex items-start gap-4 mb-4">
<!-- App Icon -->
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<!-- App Info -->
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<!-- Uninstall Icon Button -->
<button
v-if="!isWebOnly"
@click="uninstallApp"
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Action Buttons (Auto Grid) -->
<div class="grid grid-cols-2 gap-2">
<button
v-if="canLaunch"
@click="launchApp"
:class="isWebOnly ? 'col-span-2' : ''"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="startApp"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="stopApp"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="restartApp"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.restart') }}
</button>
</template>
</div>
</div>
</div>
<AppHeroSection
:pkg="pkg"
:app-id="appId"
:package-key="packageKey"
:can-launch="canLaunch"
:is-web-only="isWebOnly"
@launch="launchApp"
@start="startApp"
@stop="stopApp"
@restart="restartApp"
@uninstall="uninstallApp"
@channels="router.push('/dashboard/apps/lnd/channels')"
/>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
>
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
</div>
<AppContentSection
:pkg="pkg"
:features="features"
:needs-bitcoin-sync="needsBitcoinSync"
:bitcoin-synced="bitcoinSynced"
:bitcoin-sync-percent="bitcoinSyncPercent"
:bitcoin-block-height="bitcoinBlockHeight"
/>
<!-- Bitcoin Sync Warning (for dependent apps) -->
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1">
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
<p class="text-white/60 text-xs mt-0.5">
Some features may be unavailable until Bitcoin finishes syncing.
Wallet connections and block data require a fully synced node.
</p>
</div>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
class="h-full rounded-full bg-orange-400 transition-all duration-500"
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
></div>
</div>
<p class="text-xs text-white/40 mt-1.5">
{{ bitcoinSyncPercent.toFixed(1) }}% synced Block {{ bitcoinBlockHeight.toLocaleString() }}
</p>
</div>
<!-- Description -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
<p class="text-white/80 leading-relaxed whitespace-pre-line">
{{ pkg.manifest.description.long }}
</p>
</div>
<!-- Features (if available) -->
<div v-if="features.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
<ul class="space-y-3">
<li
v-for="(feature, index) in features"
:key="index"
class="flex items-start gap-3 text-white/80"
>
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>{{ feature }}</span>
</li>
</ul>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- App Info Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
</div>
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
</div>
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
<span class="text-white font-medium">App</span>
</div>
</div>
</div>
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 py-2 border-b border-white/10">
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
</div>
</div>
<div class="flex items-center gap-3 py-2">
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
</div>
</div>
</div>
</div>
<!-- Access (LAN + Tor) Card -->
<div v-if="interfaceAddresses" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
<div class="space-y-3">
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
<a
:href="lanUrl"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 text-sm break-all"
>
{{ interfaceAddresses['lan-address'] }}
</a>
</div>
</div>
<div v-if="isRealOnionAddress(interfaceAddresses['tor-address'])" class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
</div>
</div>
</div>
</div>
<!-- Requirements Card (hidden for web-only apps) -->
<div v-if="!isWebOnly" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
</div>
</div>
</div>
</div>
<!-- Links Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
<div class="space-y-2">
<a
v-if="pkg.manifest.website"
:href="pkg.manifest.website"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{{ t('appDetails.website') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ t('appDetails.sourceCode') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('appDetails.documentation') }}
</a>
</div>
</div>
</div>
<AppSidebar
:pkg="pkg"
:package-key="packageKey"
:is-web-only="isWebOnly"
:gateway-state="gatewayState"
:interface-addresses="interfaceAddresses"
:lan-url="lanUrl"
:tor-url="torUrl"
:show-tor-address="showTorAddress"
/>
</div>
</div>
@@ -437,7 +68,7 @@
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
</div>
<!-- Uninstall Confirmation Modal Teleport to body to escape sidebar stacking context -->
<!-- Uninstall Confirmation Modal -->
<Teleport to="body">
<Transition name="modal">
<div
@@ -502,10 +133,20 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { dummyApps } from '../utils/dummyApps'
import rpcClient from '@/api/rpc-client'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import {
WEB_ONLY_APP_URLS,
PACKAGE_ALIASES,
BITCOIN_DEPENDENT_APPS,
APP_URLS,
resolvePackageKey,
isRealOnionAddress,
} from './appDetails/appDetailsData'
const router = useRouter()
const route = useRoute()
@@ -521,86 +162,20 @@ const appId = computed(() => {
return id
})
// Web-only app detection (no container — external websites)
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': `${window.location.protocol}//${window.location.hostname}:7777`,
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
/** Map route/marketplace app IDs to backend package keys (container names). */
const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
mempool: 'mempool-web',
'mempool-electrs': 'mempool-electrs',
electrs: 'mempool-electrs',
btcpay: 'btcpay-server',
'btcpay-server': 'btcpay-server',
fedimint: 'fedimint',
'fedimint-gateway': 'fedimint-gateway',
lnd: 'lnd',
'lnd-ui': 'lnd',
bitcoin: 'bitcoin-knots',
'bitcoin-knots': 'bitcoin-knots',
homeassistant: 'homeassistant',
'home-assistant': 'homeassistant',
grafana: 'grafana',
searxng: 'searxng',
ollama: 'ollama',
onlyoffice: 'onlyoffice',
penpot: 'penpot',
nextcloud: 'nextcloud',
vaultwarden: 'vaultwarden',
jellyfin: 'jellyfin',
photoprism: 'photoprism',
immich: 'immich',
filebrowser: 'filebrowser',
'nginx-proxy-manager': 'nginx-proxy-manager',
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
}
/** Backend may register under variant container names */
const PACKAGE_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
function resolvePackageKey(routeId: string): string {
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
}
// Check both store.packages and dummyApps; resolve route ID to package key for backend data
const pkg = computed(() => {
const routeId = appId.value
const packageKey = resolvePackageKey(routeId)
// First check real packages (try both route id and resolved key)
if (store.packages[packageKey]) {
return store.packages[packageKey]
}
if (store.packages[routeId]) {
return store.packages[routeId]
}
// Check known aliases (backend may use variant container names)
const pkgKey = resolvePackageKey(routeId)
if (store.packages[pkgKey]) return store.packages[pkgKey]
if (store.packages[routeId]) return store.packages[routeId]
const aliases = PACKAGE_ALIASES[routeId]
if (aliases) {
for (const alias of aliases) {
if (store.packages[alias]) {
return store.packages[alias]
}
if (store.packages[alias]) return store.packages[alias]
}
}
// Fall back to dummy apps
if (dummyApps[routeId]) {
return dummyApps[routeId]
}
if (dummyApps[routeId]) return dummyApps[routeId]
return null
})
@@ -611,38 +186,28 @@ const interfaceAddresses = computed(() => {
return main
})
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
function isRealOnionAddress(addr: string | undefined): boolean {
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
}
const lanUrl = computed(() => {
const addr = interfaceAddresses.value?.['lan-address']
if (!addr) return '#'
if (addr.includes('localhost')) {
return addr.replace('localhost', window.location.hostname)
}
if (addr.includes('localhost')) return addr.replace('localhost', window.location.hostname)
return addr
})
/** Tor URL with http:// prefix for copy-paste into Tor Browser */
const torUrl = computed(() => {
const addr = interfaceAddresses.value?.['tor-address']
if (!addr || !isRealOnionAddress(addr)) return ''
return addr.startsWith('http') ? addr : `http://${addr}`
})
/** Resolved package key for the current route */
const showTorAddress = computed(() => isRealOnionAddress(interfaceAddresses.value?.['tor-address']))
const packageKey = computed(() => resolvePackageKey(appId.value))
/** Fedimint Gateway companion container state */
const gatewayState = computed(() => {
const gw = store.packages['fedimint-gateway']
return gw ? gw.state : 'not installed'
})
/** Apps that depend on Bitcoin being synced */
const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver']
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
const bitcoinSyncPercent = ref(0)
const bitcoinBlockHeight = ref(0)
@@ -667,7 +232,6 @@ onMounted(() => {
loadBitcoinSync()
})
// Action error toast
const actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
@@ -677,16 +241,15 @@ function showActionError(msg: string) {
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
const uninstallModal = ref({
show: false,
appTitle: ''
})
const uninstallModal = ref({ show: false, appTitle: '' })
const uninstallModalRef = ref<HTMLElement | null>(null)
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
function closeUninstallModal() {
uninstallRestoreFocusRef.value?.focus?.()
uninstallModal.value.show = false
}
useModalKeyboard(
uninstallModalRef,
computed(() => uninstallModal.value.show),
@@ -694,211 +257,53 @@ useModalKeyboard(
{ restoreFocusRef: uninstallRestoreFocusRef }
)
// Determine back button text based on where user came from
const backButtonText = computed(() => {
if (route.query.from === 'discover') {
return 'Back to Discover'
}
if (route.query.from === 'marketplace') {
return t('appDetails.backToStore')
}
if (route.query.from === 'discover') return 'Back to Discover'
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
return t('appDetails.backToApps')
})
// Check if app has a UI interface and is running
const canLaunch = computed(() => {
if (!pkg.value) return false
// Web-only apps are always launchable
if (isWebOnly.value) return true
// For real apps, check for UI interface
const hasUI = pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main
const isRunning = pkg.value.state === 'running'
return hasUI && isRunning
})
// Placeholder features - could be extracted from manifest later
const features = computed(() => {
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
]
})
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
const currentSrc = target.src
const id = appId.value
// If it's a dummy app, try to get icon from GitHub or use placeholder
if (dummyApps[id]) {
// Try alternative icon paths
const iconPaths = [
`https://raw.githubusercontent.com/Start9Labs/${id}-startos/main/icon.png`,
`https://raw.githubusercontent.com/Start9Labs/${id}-startos/main/icon.svg`,
`/assets/img/app-icons/${id}.png`,
`/assets/img/app-icons/${id}.svg`,
]
// Try next path if available
const currentIndex = iconPaths.findIndex(path => currentSrc.includes(path))
if (currentIndex < iconPaths.length - 1) {
const nextPath = iconPaths[currentIndex + 1]
if (nextPath !== undefined) {
target.src = nextPath
return
}
}
}
// Create a simple placeholder SVG
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="80" height="80" rx="16" fill="rgba(255,255,255,0.1)"/>
<path d="M40 25L50 35H45V50H35V35H30L40 25Z" fill="rgba(255,255,255,0.6)"/>
<path d="M25 55H55V60H25V55Z" fill="rgba(255,255,255,0.4)"/>
</svg>
`)}`
// Only set fallback if we haven't already tried it
if (!currentSrc.includes('data:image')) {
target.src = placeholderSvg
} else {
// Ultimate fallback
target.src = '/assets/img/logo-archipelago.svg'
}
}
const features = computed(() => [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
])
function goBack() {
router.back()
}
function launchApp() {
if (!pkg.value) return
const isDev = import.meta.env.DEV
const id = appId.value
// Web-only apps — use their external URL directly
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
return
}
// Special handling for apps with Docker containers
const appUrls: Record<string, { dev: string, prod: string }> = {
'lorabell': {
dev: 'http://192.168.1.166',
prod: 'http://192.168.1.166'
},
'atob': {
dev: 'http://localhost:8102',
prod: 'https://app.atobitcoin.io'
},
'k484': {
dev: 'http://localhost:8103',
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
},
// Dummy apps - replace with real URLs when packaged
'bitcoin': {
dev: 'http://localhost:8332',
prod: 'http://localhost:8332'
},
'btcpay-server': {
dev: 'http://localhost:23000',
prod: 'http://localhost:23000'
},
'homeassistant': {
dev: 'http://localhost:8123',
prod: 'http://localhost:8123'
},
'grafana': {
dev: 'http://localhost:3000',
prod: 'http://localhost:3000'
},
'endurain': {
dev: 'http://localhost:8080',
prod: 'http://localhost:8080'
},
'fedimint': {
dev: 'http://localhost:8175',
prod: 'http://192.168.1.228:8175'
},
'fedimint-gateway': {
dev: 'http://localhost:8176',
prod: 'http://192.168.1.228:8176'
},
'morphos-server': {
dev: 'http://localhost:8081',
prod: 'http://localhost:8081'
},
'lightning-stack': {
dev: 'http://localhost:9735',
prod: 'http://localhost:9735'
},
'mempool': {
dev: 'http://localhost:4080',
prod: 'http://localhost:4080'
},
'ollama': {
dev: 'http://localhost:11434',
prod: 'http://localhost:11434'
},
'searxng': {
dev: 'http://localhost:8888',
prod: 'http://localhost:8888'
},
'onlyoffice': {
dev: 'http://localhost:9980',
prod: 'http://localhost:9980'
},
'penpot': {
dev: 'http://localhost:9001',
prod: 'http://localhost:9001'
},
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' }
}
if (appUrls[id]) {
let url = isDev ? appUrls[id].dev : appUrls[id].prod
// Replace localhost with current hostname for remote access
if (APP_URLS[id]) {
let url = isDev ? APP_URLS[id].dev : APP_URLS[id].prod
if (url.includes('localhost')) {
url = url.replace('localhost', window.location.hostname)
}
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
return
}
// For other apps, construct the launch URL
// In a real deployment, this would use the Tor or LAN address from interfaces
const torAddress = pkg.value.manifest.interfaces?.main?.['tor-config']
const lanConfig = pkg.value.manifest.interfaces?.main?.['lan-config']
if (torAddress || lanConfig) {
showActionError(t('appDetails.noLaunchUrl'))
}
@@ -930,15 +335,11 @@ async function restartApp() {
function showUninstallModal() {
if (!pkg.value) return
uninstallModal.value = {
show: true,
appTitle: pkg.value.manifest.title
}
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
}
async function confirmUninstall() {
uninstallModal.value.show = false
try {
await store.uninstallPackage(appId.value)
router.push('/dashboard/apps').catch(() => {})
@@ -947,60 +348,9 @@ async function confirmUninstall() {
}
}
// Keep for backwards compatibility but redirect to modal
async function uninstallApp() {
function uninstallApp() {
showUninstallModal()
}
function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
switch (state) {
case PackageState.Running:
return 'bg-green-500/20 text-green-200 border border-green-500/30'
case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200 border border-red-500/30'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
default:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
}
}
function getStatusDotClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
switch (state) {
case PackageState.Running:
return 'bg-green-400'
case PackageState.Stopped:
return 'bg-gray-400'
case PackageState.Exited:
return 'bg-red-400 animate-pulse'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
return 'bg-yellow-400 animate-pulse'
case PackageState.Installing:
return 'bg-blue-400 animate-pulse'
default:
return 'bg-gray-400'
}
}
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
</script>
<style scoped>
+11 -4
View File
@@ -315,7 +315,7 @@ let appsAnimationDone = false
</script>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@@ -365,8 +365,15 @@ function isServiceContainer(id: string): boolean {
return false
}
// Search
// Search (debounced to avoid filtering on every keystroke)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
})
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
// Category filter (same categories as App Store)
const selectedCategory = ref('all')
@@ -527,8 +534,8 @@ const sortedPackageEntries = computed(() => {
})
const filteredPackageEntries = computed(() => {
if (!searchQuery.value) return sortedPackageEntries.value
const q = searchQuery.value.toLowerCase()
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
const q = debouncedSearchQuery.value.toLowerCase()
return sortedPackageEntries.value.filter(([id, pkg]) =>
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
+2
View File
@@ -80,6 +80,7 @@
v-for="(item, idx) in desktopNavItems"
:key="item.path"
:to="item.path"
aria-current-value="page"
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('/discover') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
@@ -298,6 +299,7 @@
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
aria-current-value="page"
@click="appLauncher.closePanel()"
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
:class="{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
<template>
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
>
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
</div>
<!-- Bitcoin Sync Warning (for dependent apps) -->
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1">
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
<p class="text-white/60 text-xs mt-0.5">
Some features may be unavailable until Bitcoin finishes syncing.
Wallet connections and block data require a fully synced node.
</p>
</div>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
class="h-full rounded-full bg-orange-400 transition-all duration-500"
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
></div>
</div>
<p class="text-xs text-white/40 mt-1.5">
{{ bitcoinSyncPercent.toFixed(1) }}% synced Block {{ bitcoinBlockHeight.toLocaleString() }}
</p>
</div>
<!-- Description -->
<div class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
<p class="text-white/80 leading-relaxed whitespace-pre-line">
{{ pkg.manifest.description.long }}
</p>
</div>
<!-- Features (if available) -->
<div v-if="features.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
<ul class="space-y-3">
<li
v-for="(feature, index) in features"
:key="index"
class="flex items-start gap-3 text-white/80"
>
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>{{ feature }}</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
pkg: Record<string, any>
features: string[]
needsBitcoinSync: boolean
bitcoinSynced: boolean
bitcoinSyncPercent: number
bitcoinBlockHeight: number
}>()
</script>
@@ -0,0 +1,212 @@
<template>
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
v-if="packageKey === 'lnd'"
@click="$emit('channels')"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
>
<svg class="w-4 h-4" 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>
{{ t('appDetails.channels') }}
</button>
<button
v-if="canLaunch"
@click="$emit('launch')"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="$emit('start')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
@click="$emit('restart')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.restart') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="$emit('stop')"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="$emit('uninstall')"
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ t('common.uninstall') }}
</button>
</template>
</div>
</div>
<!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden">
<div class="flex items-start gap-4 mb-4">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health)"
>
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health)"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<button
v-if="!isWebOnly"
@click="$emit('uninstall')"
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Action Buttons (Auto Grid) -->
<div class="grid grid-cols-2 gap-2">
<button
v-if="canLaunch"
@click="$emit('launch')"
:class="isWebOnly ? 'col-span-2' : ''"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="$emit('start')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="$emit('stop')"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="$emit('restart')"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ t('common.restart') }}
</button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
const { t } = useI18n()
defineProps<{
pkg: Record<string, any>
appId: string
packageKey: string
canLaunch: boolean
isWebOnly: boolean
}>()
defineEmits<{
launch: []
start: []
stop: []
restart: []
uninstall: []
channels: []
}>()
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
if (!target.src.includes('data:image') && !target.src.includes('logo-archipelago')) {
target.src = '/assets/img/logo-archipelago.svg'
}
}
</script>
@@ -0,0 +1,163 @@
<template>
<div class="space-y-6">
<!-- App Info Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
</div>
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
</div>
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
<span class="text-white font-medium">App</span>
</div>
</div>
</div>
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 py-2 border-b border-white/10">
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
</div>
</div>
<div class="flex items-center gap-3 py-2">
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
</div>
</div>
</div>
</div>
<!-- Access (LAN + Tor) Card -->
<div v-if="interfaceAddresses" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
<div class="space-y-3">
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
<a
:href="lanUrl"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 text-sm break-all"
>
{{ interfaceAddresses['lan-address'] }}
</a>
</div>
</div>
<div v-if="showTorAddress" class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
</div>
</div>
</div>
</div>
<!-- Requirements Card (hidden for web-only apps) -->
<div v-if="!isWebOnly" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
</div>
</div>
</div>
</div>
<!-- Links Card -->
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
<div class="space-y-2">
<a
v-if="pkg.manifest.website"
:href="pkg.manifest.website"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{{ t('appDetails.website') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ t('appDetails.sourceCode') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('appDetails.documentation') }}
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
pkg: Record<string, any>
packageKey: string
isWebOnly: boolean
gatewayState: string
interfaceAddresses: Record<string, string> | null
lanUrl: string
torUrl: string
showTorAddress: boolean
}>()
</script>
@@ -0,0 +1,158 @@
/**
* AppDetails data: URL maps, route-to-package mappings, aliases, and status helpers.
* Extracted from AppDetails.vue to keep the view under 500 lines.
*/
import { PackageState } from '@/types/api'
/** Web-only app detection (no container -- external websites) */
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': `${window.location.protocol}//${window.location.hostname}:7777`,
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
/** Map route/marketplace app IDs to backend package keys (container names). */
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
mempool: 'mempool-web',
'mempool-electrs': 'mempool-electrs',
electrs: 'mempool-electrs',
btcpay: 'btcpay-server',
'btcpay-server': 'btcpay-server',
fedimint: 'fedimint',
'fedimint-gateway': 'fedimint-gateway',
lnd: 'lnd',
'lnd-ui': 'lnd',
bitcoin: 'bitcoin-knots',
'bitcoin-knots': 'bitcoin-knots',
homeassistant: 'homeassistant',
'home-assistant': 'homeassistant',
grafana: 'grafana',
searxng: 'searxng',
ollama: 'ollama',
onlyoffice: 'onlyoffice',
penpot: 'penpot',
nextcloud: 'nextcloud',
vaultwarden: 'vaultwarden',
jellyfin: 'jellyfin',
photoprism: 'photoprism',
immich: 'immich',
filebrowser: 'filebrowser',
'nginx-proxy-manager': 'nginx-proxy-manager',
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
}
/** Backend may register under variant container names */
export const PACKAGE_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
export function resolvePackageKey(routeId: string): string {
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
}
/** Apps that depend on Bitcoin being synced */
export const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver']
/** App launch URLs for dev and prod environments */
export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'lorabell': { dev: 'http://192.168.1.166', prod: 'http://192.168.1.166' },
'atob': { dev: 'http://localhost:8102', prod: 'https://app.atobitcoin.io' },
'k484': { dev: 'http://localhost:8103', prod: 'http://localhost:8103' },
'indeedhub': { dev: 'https://archipelago.indeehub.studio', prod: 'https://archipelago.indeehub.studio' },
'bitcoin': { dev: 'http://localhost:8332', prod: 'http://localhost:8332' },
'btcpay-server': { dev: 'http://localhost:23000', prod: 'http://localhost:23000' },
'homeassistant': { dev: 'http://localhost:8123', prod: 'http://localhost:8123' },
'grafana': { dev: 'http://localhost:3000', prod: 'http://localhost:3000' },
'endurain': { dev: 'http://localhost:8080', prod: 'http://localhost:8080' },
'fedimint': { dev: 'http://localhost:8175', prod: 'http://192.168.1.228:8175' },
'fedimint-gateway': { dev: 'http://localhost:8176', prod: 'http://192.168.1.228:8176' },
'morphos-server': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'lightning-stack': { dev: 'http://localhost:9735', prod: 'http://localhost:9735' },
'mempool': { dev: 'http://localhost:4080', prod: 'http://localhost:4080' },
'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' },
'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' },
'onlyoffice': { dev: 'http://localhost:9980', prod: 'http://localhost:9980' },
'penpot': { dev: 'http://localhost:9001', prod: 'http://localhost:9001' },
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' },
}
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
export function isRealOnionAddress(addr: string | undefined): boolean {
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
}
export function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
switch (state) {
case PackageState.Running:
return 'bg-green-500/20 text-green-200 border border-green-500/30'
case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200 border border-red-500/30'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
default:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
}
}
export function getStatusDotClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-400 animate-pulse'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-400 animate-pulse'
switch (state) {
case PackageState.Running:
return 'bg-green-400'
case PackageState.Stopped:
return 'bg-gray-400'
case PackageState.Exited:
return 'bg-red-400 animate-pulse'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
return 'bg-yellow-400 animate-pulse'
case PackageState.Installing:
return 'bg-blue-400 animate-pulse'
default:
return 'bg-gray-400'
}
}
export function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
+122
View File
@@ -0,0 +1,122 @@
<template>
<div
data-controller-container
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
:class="{ 'home-card-animate': animate }"
style="--card-stagger: 4"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
</div>
<RouterLink to="/dashboard/server" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-1 gap-4 flex-1 min-h-0" :class="stats.bitcoinAvailable ? 'sm:grid-cols-4' : 'sm:grid-cols-3'">
<template v-if="!loaded">
<div v-for="i in 3" :key="i" class="p-4 bg-white/5 rounded-lg animate-pulse">
<div class="flex items-center justify-between mb-2">
<div class="w-8 h-3 bg-white/10 rounded"></div>
<div class="w-12 h-4 bg-white/10 rounded"></div>
</div>
<div class="w-full h-2 bg-white/10 rounded-full"></div>
</div>
</template>
<template v-else>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(stats.cpuPercent)">{{ (stats.cpuPercent ?? 0).toFixed(0) }}%</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.cpuPercent)" :style="{ width: stats.cpuPercent + '%' }"></div>
</div>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">{{ t('home.ram') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(stats.memPercent)">{{ formatBytes(stats.memUsed) }} / {{ formatBytes(stats.memTotal) }}</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.memPercent)" :style="{ width: stats.memPercent + '%' }"></div>
</div>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">{{ t('home.disk') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(stats.diskPercent)">{{ formatBytes(stats.diskUsed) }} / {{ formatBytes(stats.diskTotal) }}</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.diskPercent)" :style="{ width: stats.diskPercent + '%' }"></div>
</div>
</div>
<div v-if="stats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-orange-400/80">Bitcoin</p>
<p class="text-sm font-medium" :class="stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400'">
{{ stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%' }}
</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="stats.bitcoinSyncPercent >= 99.9 ? 'bg-green-400' : 'bg-orange-400'" :style="{ width: Math.min(stats.bitcoinSyncPercent, 100) + '%' }"></div>
</div>
<p class="text-xs text-white/40 mt-1">Block {{ stats.bitcoinBlockHeight.toLocaleString() }}</p>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
animate: boolean
loaded: boolean
stats: {
cpuPercent: number
memUsed: number
memTotal: number
memPercent: number
diskUsed: number
diskTotal: number
diskPercent: number
bitcoinSyncPercent: number
bitcoinBlockHeight: number
bitcoinAvailable: boolean
}
uptimeDisplay: string
}>()
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const val = bytes / Math.pow(1024, i)
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`
}
function gaugeTextColor(pct: number): string {
if (pct >= 90) return 'text-red-400'
if (pct >= 70) return 'text-orange-400'
return 'text-green-400'
}
function gaugeBarColor(pct: number): string {
if (pct >= 90) return 'bg-red-400'
if (pct >= 70) return 'bg-orange-400'
return 'bg-green-400'
}
</script>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animate }"
style="--card-stagger: 3"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h2 class="text-xl font-semibold text-white mb-1">{{ t('web5.wallet') }}</h2>
<p class="text-sm text-white/70">{{ walletConnected ? t('common.connected') : t('common.disconnected') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="incomingTxCount > 0 ? (showIncomingTxPanel = !showIncomingTxPanel) : $emit('showTransactions')"
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
class="shrink-0"
>
<template v-if="incomingTxCount > 0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
<span>Incoming {{ incomingTxCount }}</span>
<span class="incoming-tx-ping"></span>
</template>
<template v-else>
<svg class="w-3.5 h-3.5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span>Transactions</span>
</template>
</button>
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
</div>
<!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" 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="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
@click="$emit('openInMempool', tx.tx_hash)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
>
<svg class="w-3.5 h-3.5" :class="tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
</div>
</div>
</div>
</transition>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold">&#x20bf;</span>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-yellow-400" 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>
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-white/80">{{ t('web5.ecash') }}</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('common.send') }}
</button>
<button @click="$emit('showReceive')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('web5.receiveBitcoin') }}
</button>
<button @click="$emit('showTransactions')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Transactions
</button>
<button v-if="isDev" @click="$emit('faucet')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
Faucet
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface WalletTransaction {
tx_hash: string
amount_sats: number
direction: 'incoming' | 'outgoing'
num_confirmations: number
time_stamp: number
total_fees: number
dest_addresses: string[]
label: string
block_height: number
}
const props = defineProps<{
animate: boolean
walletConnected: boolean
walletOnchain: number
walletLightning: number
walletEcash: number
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
defineEmits<{
showSend: []
showReceive: []
showTransactions: []
faucet: []
openInMempool: [txHash: string]
}>()
const showIncomingTxPanel = ref(false)
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
</script>
@@ -0,0 +1,190 @@
<template>
<div
data-controller-container
:data-controller-install="!(installed || installing) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
role="link"
class="glass-card p-6 hover:bg-orange-500/5 hover:border-orange-500/15 transition-all cursor-pointer flex flex-col"
:class="{ 'card-stagger': stagger }"
:style="{ '--stagger-index': index }"
@click="$emit('view', app)"
@keydown.enter="$emit('view', app)"
>
<div class="flex items-start gap-4 mb-4">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title"
class="w-16 h-16 rounded-lg object-cover"
@error="handleImageError"
/>
<div v-else class="w-16 h-16 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold text-white mb-1">
{{ app.title }}
<span
v-if="tierLabel !== 'optional'"
class="tier-badge"
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tierLabel }}</span>
</h3>
<p class="text-sm text-white/60">{{ app.version ? `v${app.version}` : 'latest' }}</p>
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
</div>
</div>
<!-- Trust badge for Nostr community apps -->
<div v-if="app.trustTier" class="flex items-center gap-2 mb-3">
<span
class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-green-400/20 text-green-400': app.trustTier === 'verified',
'bg-yellow-400/20 text-yellow-400': app.trustTier === 'community',
'bg-orange-400/20 text-orange-400': app.trustTier === 'unverified',
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
}"
>{{ app.trustTier }}</span>
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
<span v-if="app.relayCount" class="text-xs text-white/40">&middot; {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
</div>
<p class="text-white/80 text-sm mb-4 line-clamp-3 flex-1">
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
</p>
<div class="flex gap-2 mt-auto">
<!-- Installed & starting up (transitional state) -->
<span
v-if="installed && startingUp"
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ installedState === 'installing' ? 'Installing...' : 'Starting...' }}
</span>
<!-- Installed & ready -->
<span
v-else-if="installed"
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
>
{{ t('marketplace.alreadyInstalled') }}
</span>
<button
v-if="installed && !startingUp"
@click.stop="$emit('launch', app)"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
{{ t('common.launch') }}
</button>
<!-- Not yet scanned show loading indicator instead of install -->
<span
v-else-if="!containersScanned && (app.source === 'local' || app.dockerImage)"
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
>
<span class="marketplace-shimmer-bg"></span>
<span class="relative flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5 opacity-60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Checking...
</span>
</span>
<!-- Installing inline progress on card -->
<div
v-else-if="!installed && installing"
class="flex-1"
>
<div class="flex items-center gap-2 mb-1.5">
<svg class="animate-spin h-4 w-4 text-blue-400 shrink-0" 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>
<span class="text-sm text-white/80 truncate">{{ installProgress?.message || t('common.installing') }}</span>
<span class="text-xs text-white/50 shrink-0">{{ installProgress?.progress || 0 }}%</span>
</div>
<div class="w-full bg-white/10 rounded-full h-1.5 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 bg-blue-500"
:style="{ width: `${installProgress?.progress || 0}%` }"
></div>
</div>
</div>
<button
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@click.stop="$emit('install', app)"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
{{ t('common.install') }}
</button>
<button
v-else-if="!installed"
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
{{ t('common.notAvailable') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
const { t } = useI18n()
const props = defineProps<{
app: MarketplaceApp
index: number
stagger: boolean
installed: boolean
installing: boolean
installProgress: InstallProgress | undefined
installedState: string | null
startingUp: boolean
containersScanned: boolean
tierLabel: string
}>()
defineEmits<{
view: [app: MarketplaceApp]
install: [app: MarketplaceApp]
launch: [app: MarketplaceApp]
}>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = '/assets/img/logo-archipelago.svg'
}
</script>
<style scoped>
.marketplace-shimmer-bg {
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 100%);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
border-radius: inherit;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,124 @@
<template>
<!-- Floating Filter Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="showModal = true"
class="md:hidden fixed right-4 z-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 only) -->
<Transition name="modal">
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="close()"
>
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
<button
@click="close()"
class="text-white/60 hover:text-white transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Category Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="category in categories"
:key="category.id"
@click="$emit('select', category.id); close()"
:class="[
'p-4 rounded-xl font-medium transition-all text-left',
selectedCategory === category.id
? 'bg-white/20 text-white border-2 border-white/40'
: 'glass-card text-white/80 hover:bg-white/10'
]"
>
<div class="flex items-center gap-3">
<!-- Category Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg v-if="category.id === 'all'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<svg v-else-if="category.id === 'community'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else-if="category.id === 'nostr'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else-if="category.id === 'commerce'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else-if="category.id === 'money'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="category.id === 'data'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<svg v-else-if="category.id === 'home'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<svg v-else-if="category.id === 'car'" class="w-6 h-6" viewBox="0 0 122.88 122.88" fill="currentColor">
<path d="M61.44,0c33.93,0,61.44,27.51,61.44,61.44c0,33.93-27.51,61.44-61.44,61.44S0,95.37,0,61.44 C0,27.51,27.51,0,61.44,0L61.44,0z M61.17,61.6c1.76,0,3.18,1.42,3.18,3.18c0,1.76-1.42,3.18-3.18,3.18 c-1.76,0-3.18-1.42-3.18-3.18C57.99,63.03,59.42,61.6,61.17,61.6L61.17,61.6z M61.2,53.28c6.34,0,11.47,5.14,11.47,11.47 c0,6.34-5.14,11.47-11.47,11.47c-6.33,0-11.47-5.14-11.47-11.47C49.73,58.41,54.87,53.28,61.2,53.28L61.2,53.28z M14.78,44.57 c4.45-12.31,13.52-22.7,24.9-28.01c15.63-7.29,34.61-7.75,50.69,4.15c9.48,7.01,12.94,12.76,17.67,22.95 c3.58,9.03,0.64,11.97-10.87,6.9c-23.79-11.77-47.84-11.24-72.12,0C16.09,56.41,11.06,51.53,14.78,44.57L14.78,44.57z M75.9,109.05 c16.62-5.23,26.32-15.81,32.27-29.3c3.87-10.43-8.26-13.97-12.52-7.1c-2.55,5.06-5.59,9.4-9.55,12.77 c-6.2,5.27-15.18,6.23-16.58,16.16C68.79,106.74,69.97,111.38,75.9,109.05L75.9,109.05z M47.26,109.05 c-16.62-5.23-26.32-15.81-32.27-29.3c-3.87-10.43,8.26-13.97,12.52-7.1c2.55,5.06,5.59,9.4,9.55,12.77 c6.2,5.27,15.18,6.23,16.58,16.16C54.37,106.74,53.19,111.38,47.26,109.05L47.26,109.05z"/>
</svg>
<svg v-else-if="category.id === 'networking'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
<div class="flex-1">
<p class="font-semibold">{{ category.name }}</p>
<p v-if="selectedCategory === category.id" class="text-xs text-white/60 mt-1">Currently viewing</p>
</div>
<svg v-if="selectedCategory === category.id" class="w-5 h-5 text-white flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const { t } = useI18n()
defineProps<{
categories: { id: string; name: string }[]
selectedCategory: string
}>()
defineEmits<{
select: [categoryId: string]
}>()
const showModal = ref(false)
const modalRef = ref<HTMLElement | null>(null)
const restoreFocusRef = ref<HTMLElement | null>(null)
function close() {
restoreFocusRef.value?.focus?.()
showModal.value = false
}
useModalKeyboard(modalRef, showModal, close, { restoreFocusRef })
</script>
@@ -0,0 +1,507 @@
/**
* Marketplace data: curated app list, categorization, aliases, and tier logic.
* Extracted from Marketplace.vue to keep the view under 500 lines.
*/
export interface MarketplaceApp {
id: string
title?: string
version?: string
description?: string | { short?: string; long?: string }
icon?: string
author?: string
dockerImage?: string
manifestUrl?: string
repoUrl?: string
webUrl?: string
category?: string
source?: string
url?: string
s9pkUrl?: string
trustScore?: number
trustTier?: string
relayCount?: number
}
export interface InstallProgress {
id: string
title: string
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
progress: number
message: string
attempt: number
}
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
electrumx: ['electrumx', 'archy-electrs-ui'],
grafana: ['grafana'],
jellyfin: ['jellyfin'],
vaultwarden: ['vaultwarden'],
searxng: ['searxng'],
homeassistant: ['homeassistant'],
photoprism: ['photoprism'],
lnd: ['lnd', 'archy-lnd-ui'],
filebrowser: ['filebrowser'],
tailscale: ['tailscale'],
ollama: ['ollama'],
}
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', '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'
}
/** Categorize community apps based on their ID and description */
export function categorizeCommunityApp(app: MarketplaceApp): string {
if (app.category) return app.category
const id = app.id.toLowerCase()
const title = app.title?.toLowerCase() || ''
const description = (typeof app.description === 'string' ? app.description : (app.description as { short?: string })?.short ?? '').toLowerCase()
const combined = `${id} ${title} ${description}`
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') ||
id.includes('lnd') || id.includes('cln') || id.includes('electr') ||
id.includes('fedimint') || id.includes('cashu') || title.includes('lightning') ||
combined.includes('wallet') || combined.includes('satoshi')) {
return 'money'
}
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
combined.includes('merchant') || combined.includes('invoice')) {
return 'commerce'
}
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('sync') ||
id.includes('storage') || id.includes('backup') || id.includes('file') ||
id.includes('photo') || id.includes('immich') || id.includes('jellyfin') ||
id.includes('plex') || id.includes('media') || id.includes('vault') ||
combined.includes('password manager') || combined.includes('file storage')) {
return 'data'
}
if (id.includes('home-assistant') || id.includes('homeassistant') ||
id.includes('smart-home') || id.includes('automation') || id.includes('iot') ||
combined.includes('home automation') || combined.includes('smart home')) {
return 'home'
}
if (id.includes('nostr') || (id.includes('relay') && combined.includes('nostr')) ||
combined.includes('nostr relay') || combined.includes('nostr client')) {
return 'nostr'
}
if (id.includes('vpn') || id.includes('wireguard') || id.includes('tailscale') ||
id.includes('proxy') || id.includes('dns') || id.includes('pihole') ||
id.includes('adguard') || id.includes('nginx') || id.includes('tor') ||
combined.includes('network') || combined.includes('firewall')) {
return 'networking'
}
if (id.includes('matrix') || id.includes('synapse') || id.includes('element') ||
id.includes('mastodon') || id.includes('lemmy') ||
id.includes('messenger') || id.includes('chat') || id.includes('social') ||
id.includes('cups') || combined.includes('communication') ||
combined.includes('messaging')) {
return 'community'
}
return 'other'
}
/** Curated list of apps with Docker Hub images */
export function getCuratedAppList(): MarketplaceApp[] {
return [
{
id: 'bitcoin-knots',
title: 'Bitcoin Knots',
version: '28.1.0',
description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.',
icon: '/assets/img/app-icons/bitcoin-knots.webp',
author: 'Bitcoin Knots',
dockerImage: 'docker.io/bitcoinknots/bitcoin:v28.1',
manifestUrl: undefined,
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',
manifestUrl: undefined,
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',
manifestUrl: undefined,
repoUrl: 'https://github.com/lightningnetwork/lnd'
},
{
id: 'thunderhub',
title: 'ThunderHub',
version: '0.13.31',
description: 'Lightning node management UI. Manage channels, send and receive payments, view 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',
manifestUrl: undefined,
repoUrl: 'https://github.com/apotdevin/thunderhub'
},
{
id: 'mempool',
title: 'Mempool Explorer',
version: '2.5.0',
description: 'Self-hosted Bitcoin blockchain and mempool visualizer with beautiful explorer interface.',
icon: '/assets/img/app-icons/mempool.webp',
author: 'Mempool',
dockerImage: 'docker.io/mempool/frontend:v2.5.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/mempool/mempool'
},
{
id: 'homeassistant',
title: 'Home Assistant',
version: '2024.1',
description: 'Open-source home automation platform. Control and automate your smart home devices privately.',
icon: '/assets/img/app-icons/homeassistant.png',
author: 'Home Assistant',
dockerImage: 'docker.io/homeassistant/home-assistant:2024.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/home-assistant/core'
},
{
id: 'grafana',
title: 'Grafana',
version: '10.2.0',
description: 'Analytics and monitoring platform. Create dashboards and visualize data from multiple sources.',
icon: '/assets/img/app-icons/grafana.png',
author: 'Grafana Labs',
dockerImage: 'docker.io/grafana/grafana:10.2.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/grafana/grafana'
},
{
id: 'searxng',
title: 'SearXNG',
version: '2024.1.0',
description: 'Privacy-respecting metasearch engine. Search without tracking or ads.',
icon: '/assets/img/app-icons/searxng.png',
author: 'SearXNG',
dockerImage: 'docker.io/searxng/searxng:2024.11.17-e2554de75',
manifestUrl: undefined,
repoUrl: 'https://github.com/searxng/searxng'
},
{
id: 'ollama',
title: 'Ollama',
version: '0.1.0',
description: 'Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware.',
icon: '/assets/img/app-icons/ollama.png',
author: 'Ollama',
dockerImage: 'docker.io/ollama/ollama:0.5.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/ollama/ollama'
},
{
id: 'onlyoffice',
title: 'OnlyOffice',
version: '7.5.1',
description: 'Office suite for document collaboration. Edit docs, spreadsheets, and presentations.',
icon: '/assets/img/app-icons/onlyoffice.webp',
author: 'Ascensio System SIA',
dockerImage: 'docker.io/onlyoffice/documentserver:7.5.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/ONLYOFFICE/DocumentServer'
},
{
id: 'penpot',
title: 'Penpot',
version: '2.4',
description: 'Open-source design and prototyping platform. Self-hosted alternative to Figma.',
icon: '/assets/img/app-icons/penpot.webp',
author: 'Penpot',
dockerImage: 'docker.io/penpotapp/frontend:2.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/penpot/penpot'
},
{
id: 'nextcloud',
title: 'Nextcloud',
version: '28.0',
description: 'Self-hosted cloud storage and collaboration platform. Your own private cloud.',
icon: '/assets/img/app-icons/nextcloud.webp',
author: 'Nextcloud',
dockerImage: 'docker.io/library/nextcloud:28',
manifestUrl: undefined,
repoUrl: 'https://github.com/nextcloud/server'
},
{
id: 'vaultwarden',
title: 'Vaultwarden',
version: '1.30.0',
description: 'Self-hosted password manager (Bitwarden-compatible). Secure vault for passwords and secrets.',
icon: '/assets/img/app-icons/vaultwarden.webp',
author: 'Vaultwarden',
dockerImage: 'docker.io/vaultwarden/server:1.30.0-alpine',
manifestUrl: undefined,
repoUrl: 'https://github.com/dani-garcia/vaultwarden'
},
{
id: 'jellyfin',
title: 'Jellyfin',
version: '10.8.0',
description: 'Free media server system. Stream your movies, music, and photos to any device.',
icon: '/assets/img/app-icons/jellyfin.webp',
author: 'Jellyfin',
dockerImage: 'docker.io/jellyfin/jellyfin:10.8.13',
manifestUrl: undefined,
repoUrl: 'https://github.com/jellyfin/jellyfin'
},
{
id: 'photoprism',
title: 'PhotoPrism',
version: '240915',
description: 'AI-powered photo management. Organize and browse photos with facial recognition.',
icon: '/assets/img/app-icons/photoprism.svg',
author: 'PhotoPrism',
dockerImage: 'docker.io/photoprism/photoprism:240915',
manifestUrl: undefined,
repoUrl: 'https://github.com/photoprism/photoprism'
},
{
id: 'immich',
title: 'Immich',
version: '1.90.0',
description: 'High-performance self-hosted photo and video backup. Mobile-first with ML features.',
icon: '/assets/img/app-icons/immich.png',
author: 'Immich',
dockerImage: 'ghcr.io/immich-app/immich-server:release',
manifestUrl: undefined,
repoUrl: 'https://github.com/immich-app/immich'
},
{
id: 'filebrowser',
title: 'File Browser',
version: '2.27.0',
description: 'Web-based file manager. Browse, upload, and manage files through a web interface.',
icon: '/assets/img/app-icons/file-browser.webp',
author: 'File Browser',
dockerImage: 'docker.io/filebrowser/filebrowser:v2.27.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/filebrowser/filebrowser'
},
{
id: 'nginx-proxy-manager',
title: 'Nginx Proxy Manager',
version: '2.11.0',
description: 'Easy proxy management with SSL. Beautiful web interface for managing reverse proxies.',
icon: '/assets/img/app-icons/nginx.svg',
author: 'Nginx Proxy Manager',
dockerImage: 'docker.io/jc21/nginx-proxy-manager:2.12.1',
manifestUrl: undefined,
repoUrl: 'https://github.com/NginxProxyManager/nginx-proxy-manager'
},
{
id: 'portainer',
title: 'Portainer',
version: '2.19.0',
description: 'Container management UI. Manage Docker containers through a beautiful web interface.',
icon: '/assets/img/app-icons/portainer.webp',
author: 'Portainer',
dockerImage: 'docker.io/portainer/portainer-ce:2.19.4',
manifestUrl: undefined,
repoUrl: 'https://github.com/portainer/portainer'
},
{
id: 'uptime-kuma',
title: 'Uptime Kuma',
version: '1.23.0',
description: 'Self-hosted monitoring tool. Monitor uptime for HTTP(s), TCP, DNS, and more.',
icon: '/assets/img/app-icons/uptime-kuma.webp',
author: 'Uptime Kuma',
dockerImage: 'docker.io/louislam/uptime-kuma:1',
manifestUrl: undefined,
repoUrl: 'https://github.com/louislam/uptime-kuma'
},
{
id: 'tailscale',
title: 'Tailscale',
version: '1.78.0',
description: 'Zero-config VPN for secure remote access. Connect all your devices with WireGuard mesh network.',
icon: '/assets/img/app-icons/tailscale.webp',
author: 'Tailscale',
dockerImage: 'docker.io/tailscale/tailscale:stable',
manifestUrl: undefined,
repoUrl: 'https://github.com/tailscale/tailscale'
},
{
id: 'fedimint',
title: 'Fedimint',
version: '0.10.0',
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
icon: '/assets/img/app-icons/fedimint.png',
author: 'Fedimint',
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
manifestUrl: undefined,
repoUrl: 'https://github.com/fedimint/fedimint'
},
{
id: 'indeedhub',
title: 'Indeehub',
version: '0.1.0',
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
icon: '/assets/img/app-icons/indeedhub.png',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
{
id: 'dwn',
title: 'Decentralized Web Node',
version: '0.4.0',
description: 'Store and sync your personal data across devices using decentralized web node protocols. Own your data with DID-based access control.',
icon: '/assets/img/app-icons/dwn.svg',
author: 'TBD',
dockerImage: 'ghcr.io/tbd54566975/dwn-server:main',
manifestUrl: undefined,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostrudel',
title: 'noStrudel',
version: '0.40.0',
category: 'nostr',
description: 'A feature-rich Nostr web client with NIP-07 signer support. Browse your feed, post notes, manage relays, and interact with the Nostr network — all signed with your node\'s Nostr identity.',
icon: '/assets/img/app-icons/nostrudel.svg',
author: 'hzrd149',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://github.com/hzrd149/nostrudel',
webUrl: 'https://nostrudel.ninja'
},
{
id: 'nostr-rs-relay',
title: 'Nostr Relay',
version: '0.9.0',
category: 'nostr',
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:0.9.0',
manifestUrl: undefined,
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. Compete in strategy tournaments with your own coded bots.',
icon: '/assets/img/app-icons/botfights.svg',
author: 'BotFights',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://botfights.net',
webUrl: 'https://botfights.net'
},
{
id: 'nwnn',
title: 'Next Web News Network',
version: '1.0.0',
category: 'l484',
description: 'Decentralized news and link aggregator, synchronized from Telegram. Community-curated content on Bitcoin, sovereignty, and decentralized tech.',
icon: '/assets/img/app-icons/nwnn.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
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 — an internal tool for the L484 network.',
icon: '/assets/img/app-icons/484-kitchen.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
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 — a portal for exploring decentralized alternatives and reclaiming digital sovereignty.',
icon: '/assets/img/app-icons/call-the-operator.png',
author: 'TX1138',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://cta.tx1138.com',
webUrl: 'https://cta.tx1138.com'
},
{
id: 'arch-presentation',
title: 'Arch Presentation',
version: '1.0.0',
category: 'l484',
description: 'Archipelago: The Future of Decentralized Infrastructure — an interactive presentation about the Archipelago project vision.',
icon: '/assets/img/app-icons/arch-presentation.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
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 analysis-therapy, digital homeopathy, and concierge protocols.',
icon: '/assets/img/app-icons/syntropy-institute.png',
author: 'Syntropy Institute',
dockerImage: '',
manifestUrl: undefined,
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, Bitcoin, and the mavericks building the ungovernable future. Conversations with the builders, powered by Nostr.',
icon: '/assets/img/app-icons/t-zero.png',
author: 'T-0',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://teeminuszero.net',
webUrl: 'https://teeminuszero.net'
}
]
}
@@ -0,0 +1,103 @@
<template>
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Service Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Services</p>
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
</div>
</div>
<button
@click="$emit('restartServices')"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
:disabled="restarting"
>
{{ restarting ? 'Restarting...' : 'Restart' }}
</button>
</div>
<!-- Tor Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
<div v-if="torStatusLabel === 'running'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Tor Status</p>
<p class="text-xs text-white/60 capitalize">{{ torStatusLabel }}</p>
</div>
</div>
<button
@click="$emit('checkTor')"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
:disabled="checkingTor"
>
{{ checkingTor ? 'Checking...' : 'Check Tor' }}
</button>
</div>
<!-- Auto-Sync Toggle -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center justify-between min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Auto-Sync</p>
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
</div>
</div>
<ToggleSwitch :model-value="autoSyncEnabled" @update:model-value="$emit('update:autoSyncEnabled', $event)" />
</div>
</div>
<!-- Logs & Diagnostics -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-white">Logs</p>
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
</div>
</div>
<button
@click="$emit('viewLogs')"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
View
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from '@/components/ToggleSwitch.vue'
defineProps<{
servicesRunning: boolean
restarting: boolean
torStatusLabel: string
torStatusColor: string
checkingTor: boolean
autoSyncEnabled: boolean
logCount: number
}>()
defineEmits<{
restartServices: []
checkTor: []
'update:autoSyncEnabled': [value: boolean]
viewLogs: []
}>()
</script>
+260
View File
@@ -0,0 +1,260 @@
<template>
<!-- Add Tor Service Modal -->
<Teleport to="body">
<div v-if="showAddServiceModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="$emit('closeAddService')" @keydown.escape="$emit('closeAddService')">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Add Tor Hidden Service</h3>
<!-- Tabs: Installed Apps | Manual -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="localTab = 'apps'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="localTab === 'apps' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Installed Apps
</button>
<button
@click="localTab = 'manual'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="localTab === 'manual' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Manual
</button>
</div>
<!-- Installed Apps tab -->
<div v-if="localTab === 'apps'">
<p class="text-white/60 text-sm mb-3">Select an installed app to create a .onion address for it.</p>
<div v-if="availableAppsForTor.length === 0" class="p-4 text-center text-white/40 text-sm">
All installed apps already have Tor services.
</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<button
v-for="app in availableAppsForTor"
:key="app.id"
@click="$emit('createServiceForApp', app.id)"
:disabled="addingService"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ app.title }}</p>
<p class="text-xs text-white/40">{{ app.id }}</p>
</div>
<span class="text-xs text-orange-400 shrink-0">+ Add</span>
</button>
</div>
</div>
<!-- Manual tab -->
<div v-if="localTab === 'manual'">
<p class="text-white/60 text-sm mb-3">Create a .onion address for any local service.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Service Name</label>
<input
v-model="localName"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="my-app"
maxlength="64"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Local Port</label>
<input
v-model.number="localPort"
type="number"
min="1"
max="65535"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="8080"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="$emit('createService', localName.trim(), localPort)"
:disabled="!localName.trim() || !localPort || addingService"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ addingService ? 'Creating...' : 'Create Service' }}
</button>
<button
@click="$emit('closeAddService')"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</div>
<p v-if="addServiceError" class="mt-3 text-sm text-red-400">{{ addServiceError }}</p>
</div>
</div>
</Teleport>
<!-- WiFi Scan Modal -->
<div v-if="showWifiModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<template v-if="wifiScanning">
<div class="space-y-3">
<div v-for="i in 4" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-12"></div>
</div>
</template>
<template v-else-if="wifiNetworks.length > 0">
<div class="space-y-2 max-h-72 overflow-y-auto">
<button
v-for="net in wifiNetworks"
:key="net.ssid"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
@click="$emit('selectWifi', net.ssid)"
>
<div>
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
<p class="text-xs text-white/50">{{ net.security || 'Open' }}</p>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-0.5">
<div v-for="bar in 4" :key="bar" class="w-1 rounded-full" :class="bar <= Math.ceil(net.signal / 25) ? 'bg-white/80' : 'bg-white/20'" :style="{ height: (bar * 3 + 4) + 'px' }"></div>
</div>
<span class="text-xs text-white/50">{{ net.signal }}%</span>
</div>
</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
</template>
<!-- WiFi password prompt -->
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
<input
v-model="localWifiPassword"
type="password"
placeholder="WiFi password"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
@keyup.enter="$emit('connectWifi', localWifiPassword)"
/>
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
<div class="flex gap-2">
<button @click="$emit('cancelWifiConnect')" :disabled="wifiSubmitting" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
<button @click="$emit('connectWifi', localWifiPassword)" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!localWifiPassword || wifiSubmitting">{{ wifiSubmitting ? 'Connecting...' : 'Connect' }}</button>
</div>
</div>
</div>
</div>
<!-- DNS Configuration Modal -->
<div v-if="showDnsModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
<button @click="$emit('closeDns')" class="text-white/40 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p class="text-sm text-white/60 mb-4">Choose a DNS provider. Providers with DoH encrypt your DNS queries.</p>
<div class="space-y-2 mb-4">
<button
v-for="opt in dnsProviderOptions"
:key="opt.value"
class="w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left"
:class="dnsSelectedProvider === opt.value ? 'bg-white/15 border border-white/20' : 'bg-white/5 border border-transparent hover:bg-white/10'"
@click="$emit('selectDnsProvider', opt.value)"
>
<div>
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
<p class="text-xs text-white/50">{{ opt.description }}</p>
</div>
<span v-if="opt.doh" class="text-xs px-2 py-0.5 rounded-full bg-green-400/20 text-green-400">DoH</span>
</button>
</div>
<!-- Custom servers input -->
<div v-if="dnsSelectedProvider === 'custom'" class="mb-4">
<label class="block text-sm text-white/70 mb-1">DNS Servers (comma-separated)</label>
<input
v-model="localCustomServers"
type="text"
placeholder="1.1.1.1, 8.8.8.8"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30"
/>
</div>
<!-- Current servers info -->
<div v-if="dnsServers.length > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/50 mb-1">Current resolv.conf servers</p>
<p class="text-sm text-white/80">{{ dnsServers.join(', ') }}</p>
</div>
<p v-if="dnsError" class="text-sm text-red-400 mb-3">{{ dnsError }}</p>
<div class="flex gap-2">
<button @click="$emit('closeDns')" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
<button
@click="$emit('applyDns', localCustomServers)"
class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium"
:disabled="dnsApplying || (dnsSelectedProvider === 'custom' && !localCustomServers.trim())"
>
{{ dnsApplying ? 'Applying...' : 'Apply' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
showAddServiceModal: boolean
showWifiModal: boolean
showDnsModal: boolean
availableAppsForTor: { id: string; title: string }[]
addingService: boolean
addServiceError: string
wifiScanning: boolean
wifiNetworks: { ssid: string; signal: number; security: string }[]
wifiConnecting: boolean
wifiSubmitting: boolean
wifiSelectedSsid: string
wifiError: string
dnsSelectedProvider: string
dnsServers: string[]
dnsApplying: boolean
dnsError: string
dnsProviderOptions: { value: string; label: string; description: string; doh: boolean }[]
}>()
defineEmits<{
closeAddService: []
createServiceForApp: [appId: string]
createService: [name: string, port: number | null]
closeWifi: []
selectWifi: [ssid: string]
connectWifi: [password: string]
cancelWifiConnect: []
closeDns: []
selectDnsProvider: [provider: string]
applyDns: [customServers: string]
}>()
const localTab = ref<'apps' | 'manual'>('apps')
const localName = ref('')
const localPort = ref<number | null>(null)
const localWifiPassword = ref('')
const localCustomServers = ref('')
</script>
@@ -0,0 +1,101 @@
<template>
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torDaemonRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torDaemonRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="$emit('restartTor')" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
</button>
<button @click="$emit('showAddService')" class="glass-button px-3 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Service
</button>
</div>
</div>
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
<div v-else class="space-y-2">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
v-if="svc.onion_address && svc.enabled"
@click="$emit('rotateService', svc.name)"
:disabled="torRotating === svc.name"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<button
v-if="svc.name !== 'archipelago'"
@click="$emit('deleteService', svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from '@/components/ToggleSwitch.vue'
export interface TorServiceInfo {
name: string
local_port: number
onion_address: string | null
enabled: boolean
unauthenticated: boolean
protocol: boolean
}
defineProps<{
torServices: TorServiceInfo[]
torServicesLoading: boolean
torDaemonRunning: boolean
torRestarting: boolean
torRotating: string | false
torDeleting: string | false
}>()
defineEmits<{
restartTor: []
showAddService: []
copyAddress: [address: string]
rotateService: [name: string]
deleteService: [name: string]
toggleApp: [name: string, enabled: boolean]
}>()
</script>