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