387 lines
12 KiB
Vue
387 lines
12 KiB
Vue
<template>
|
|
<div class="app-details-container pb-16 md:pb-16">
|
|
<!-- Desktop Back Button -->
|
|
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 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="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
{{ backButtonText }}
|
|
</button>
|
|
|
|
<!-- Mobile Full-Width Back Button (teleported to escape CSS transform containing block) -->
|
|
<Teleport to="body">
|
|
<button
|
|
@click="goBack"
|
|
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
|
>
|
|
<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="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
<span>{{ backButtonText }}</span>
|
|
</button>
|
|
</Teleport>
|
|
|
|
<div v-if="pkg">
|
|
<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">
|
|
<AppContentSection
|
|
:pkg="pkg"
|
|
:features="features"
|
|
:needs-bitcoin-sync="needsBitcoinSync"
|
|
:bitcoin-synced="bitcoinSynced"
|
|
:bitcoin-sync-percent="bitcoinSyncPercent"
|
|
:bitcoin-block-height="bitcoinBlockHeight"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<!-- App Not Found -->
|
|
<div v-else class="glass-card p-12 text-center">
|
|
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
|
|
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
|
</div>
|
|
|
|
<!-- Uninstall Confirmation Modal -->
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="uninstallModal.show"
|
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
|
@click="closeUninstallModal()"
|
|
>
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
|
<div
|
|
ref="uninstallModalRef"
|
|
@click.stop
|
|
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
|
>
|
|
<div class="flex items-start gap-4 mb-6">
|
|
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
|
<svg class="w-6 h-6 text-red-400" 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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
|
<p class="text-white/70 text-sm">
|
|
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
|
<button
|
|
@click="closeUninstallModal()"
|
|
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
@click="confirmUninstall"
|
|
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.uninstall') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Action error toast -->
|
|
<Transition name="fade">
|
|
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
|
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
|
<span>{{ actionError }}</span>
|
|
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '../stores/app'
|
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
|
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()
|
|
const store = useAppStore()
|
|
const { t } = useI18n()
|
|
|
|
const appId = computed(() => {
|
|
const id = route.params.id
|
|
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
|
router.replace('/apps')
|
|
return ''
|
|
}
|
|
return id
|
|
})
|
|
|
|
const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
|
|
|
|
const pkg = computed(() => {
|
|
const routeId = appId.value
|
|
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 (dummyApps[routeId]) return dummyApps[routeId]
|
|
return null
|
|
})
|
|
|
|
const interfaceAddresses = computed(() => {
|
|
const main = pkg.value?.installed?.['interface-addresses']?.main
|
|
if (!main) return null
|
|
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
|
return main
|
|
})
|
|
|
|
const lanUrl = computed(() => {
|
|
const addr = interfaceAddresses.value?.['lan-address']
|
|
if (!addr) return '#'
|
|
if (addr.includes('localhost')) return addr.replace('localhost', window.location.hostname)
|
|
return addr
|
|
})
|
|
|
|
const torUrl = computed(() => {
|
|
const addr = interfaceAddresses.value?.['tor-address']
|
|
if (!addr || !isRealOnionAddress(addr)) return ''
|
|
return addr.startsWith('http') ? addr : `http://${addr}`
|
|
})
|
|
|
|
const showTorAddress = computed(() => isRealOnionAddress(interfaceAddresses.value?.['tor-address']))
|
|
|
|
const packageKey = computed(() => resolvePackageKey(appId.value))
|
|
|
|
const gatewayState = computed(() => {
|
|
const gw = store.packages['fedimint-gateway']
|
|
return gw ? gw.state : 'not installed'
|
|
})
|
|
|
|
const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageKey.value))
|
|
const bitcoinSyncPercent = ref(0)
|
|
const bitcoinBlockHeight = ref(0)
|
|
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
|
|
|
async function loadBitcoinSync() {
|
|
if (!needsBitcoinSync.value) return
|
|
try {
|
|
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
|
method: 'bitcoin.getinfo',
|
|
timeout: 5000,
|
|
})
|
|
bitcoinSyncPercent.value = (btc.sync_progress ?? 0) * 100
|
|
bitcoinBlockHeight.value = btc.block_height ?? 0
|
|
} catch {
|
|
bitcoinSyncPercent.value = 0
|
|
bitcoinBlockHeight.value = 0
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadBitcoinSync()
|
|
})
|
|
|
|
const actionError = ref('')
|
|
let errorTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
function showActionError(msg: string) {
|
|
actionError.value = msg
|
|
if (errorTimer) clearTimeout(errorTimer)
|
|
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
|
}
|
|
|
|
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),
|
|
closeUninstallModal,
|
|
{ restoreFocusRef: uninstallRestoreFocusRef }
|
|
)
|
|
|
|
const backButtonText = computed(() => {
|
|
if (route.query.from === 'discover') return 'Back to Discover'
|
|
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
|
return t('appDetails.backToApps')
|
|
})
|
|
|
|
const canLaunch = computed(() => {
|
|
if (!pkg.value) return false
|
|
if (isWebOnly.value) return true
|
|
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main)
|
|
const isRunning = pkg.value.state === 'running'
|
|
return hasUI && isRunning
|
|
})
|
|
|
|
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
|
|
|
|
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
|
if (webOnlyUrl) {
|
|
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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'))
|
|
}
|
|
}
|
|
|
|
async function startApp() {
|
|
try {
|
|
await store.startPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
async function stopApp() {
|
|
try {
|
|
await store.stopPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
async function restartApp() {
|
|
try {
|
|
await store.restartPackage(appId.value)
|
|
} catch (err) {
|
|
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
function showUninstallModal() {
|
|
if (!pkg.value) return
|
|
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(() => {})
|
|
} catch (err) {
|
|
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
function uninstallApp() {
|
|
showUninstallModal()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-enter-active,
|
|
.modal-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-enter-active .glass-card,
|
|
.modal-leave-active .glass-card {
|
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-enter-from,
|
|
.modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.modal-enter-from .glass-card,
|
|
.modal-leave-to .glass-card {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|