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
parent afd7405b1a
commit 69e25410b0
20 changed files with 2713 additions and 3206 deletions

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)

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);
}
}

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>

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) ||

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}

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>

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>

View File

@ -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>

View File

@ -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>

View File

@ -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'
}
]
}

View File

@ -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>

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>

View File

@ -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>