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