- Introduced LoraBell as a static demo app in the mock backend, preventing its uninstallation. - Merged static dev apps with Docker container data for improved package management. - Updated app details and URLs for LoraBell in the Apps and AppDetails views. - Enhanced the dummyApps utility to include LoraBell's configuration for consistent app representation.
815 lines
33 KiB
Vue
815 lines
33 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 -->
|
|
<button
|
|
@click="goBack"
|
|
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
|
:style="{
|
|
bottom: bottomPosition,
|
|
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
|
}"
|
|
>
|
|
<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>
|
|
|
|
<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"
|
|
: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)"
|
|
>
|
|
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state)"></span>
|
|
{{ pkg.state }}
|
|
</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="canLaunch"
|
|
@click="launchApp"
|
|
class="gradient-button 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>
|
|
Launch
|
|
</button>
|
|
<button
|
|
v-if="pkg.state === 'stopped'"
|
|
@click="startApp"
|
|
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-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="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>
|
|
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>
|
|
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>
|
|
Stop
|
|
</button>
|
|
<button
|
|
@click="uninstallApp"
|
|
class="px-4 py-2.5 bg-red-600/20 border border-red-600/40 rounded-lg text-red-300 text-sm font-medium hover:bg-red-600/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="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>
|
|
Uninstall
|
|
</button>
|
|
</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"
|
|
: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)"
|
|
>
|
|
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state)"></span>
|
|
{{ pkg.state }}
|
|
</span>
|
|
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Uninstall Icon Button -->
|
|
<button
|
|
@click="uninstallApp"
|
|
class="flex-shrink-0 w-9 h-9 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
|
title="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="gradient-button 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>
|
|
Launch
|
|
</button>
|
|
<button
|
|
v-if="pkg.state === 'stopped'"
|
|
@click="startApp"
|
|
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-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="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>
|
|
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>
|
|
Stop
|
|
</button>
|
|
<button
|
|
@click="restartApp"
|
|
:class="[canLaunch && (pkg.state === 'stopped' || 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>
|
|
Restart
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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">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">Screenshot placeholders - images coming soon</p>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="glass-card p-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">About {{ 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">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">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">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">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">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">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">Category</span>
|
|
<span class="text-white font-medium">App</span>
|
|
</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">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">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">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">Requires Tor Browser</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Requirements Card -->
|
|
<div class="glass-card p-6">
|
|
<h3 class="text-lg font-bold text-white mb-4">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">RAM</p>
|
|
<p class="text-white/60 text-sm">Minimum 512MB</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">Storage</p>
|
|
<p class="text-white/60 text-sm">~100MB</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Links Card -->
|
|
<div class="glass-card p-6">
|
|
<h3 class="text-lg font-bold text-white mb-4">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>
|
|
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>
|
|
Source Code
|
|
</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>
|
|
Documentation
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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">App Not Found</h3>
|
|
<p class="text-white/70">The requested application could not be found</p>
|
|
</div>
|
|
|
|
<!-- Spacer for mobile back button -->
|
|
<div class="md:hidden h-[calc(var(--mobile-tab-bar-height,_64px)+96px)]"></div>
|
|
|
|
<!-- Uninstall Confirmation Modal -->
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="uninstallModal.show"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
@click="closeUninstallModal()"
|
|
>
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></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">Uninstall App?</h3>
|
|
<p class="text-white/70 text-sm">
|
|
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
|
This will remove the app and stop its container.
|
|
</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"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="confirmUninstall"
|
|
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
|
>
|
|
Uninstall
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { useAppStore } from '../stores/app'
|
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
|
import { PackageState } from '../types/api'
|
|
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
import { dummyApps } from '../utils/dummyApps'
|
|
|
|
const { bottomPosition } = useMobileBackButton()
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const store = useAppStore()
|
|
|
|
const appId = computed(() => route.params.id as string)
|
|
|
|
/** 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',
|
|
lnd: 'lnd',
|
|
'lnd-ui': 'lnd',
|
|
bitcoin: 'bitcoin-knots',
|
|
'bitcoin-knots': 'bitcoin-knots',
|
|
}
|
|
|
|
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]
|
|
}
|
|
// Fall back to dummy apps
|
|
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
|
|
})
|
|
|
|
/** 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)
|
|
}
|
|
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}`
|
|
})
|
|
|
|
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 }
|
|
)
|
|
|
|
// Determine back button text based on where user came from
|
|
const backButtonText = computed(() => {
|
|
// Check if we came from marketplace via query parameter
|
|
if (route.query.from === 'marketplace') {
|
|
return 'Back to App Store'
|
|
}
|
|
|
|
// Default to My Apps
|
|
return 'Back to My Apps'
|
|
})
|
|
|
|
// Check if app has a UI interface and is running
|
|
const canLaunch = computed(() => {
|
|
if (!pkg.value) return false
|
|
// For dummy apps, allow launch if running (they have interface addresses)
|
|
// 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'
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
router.back()
|
|
}
|
|
|
|
function launchApp() {
|
|
if (!pkg.value) return
|
|
|
|
const isDev = import.meta.env.DEV
|
|
const id = appId.value
|
|
|
|
// Special handling for apps with Docker containers
|
|
// TODO: Replace dummy app URLs with real URLs when apps are packaged
|
|
const appUrls: Record<string, { dev: string, prod: string }> = {
|
|
'lorabell': {
|
|
dev: '/lorabell-info.html',
|
|
prod: '/lorabell-info.html'
|
|
},
|
|
'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: 'http://localhost:7777',
|
|
prod: 'http://localhost:7777' // Containerized indeehub prototype
|
|
},
|
|
// Dummy apps - replace with real URLs when packaged
|
|
'bitcoin': {
|
|
dev: 'http://localhost:8332',
|
|
prod: 'http://localhost:8332'
|
|
},
|
|
'btcpay-server': {
|
|
dev: 'http://localhost:14142',
|
|
prod: 'http://localhost:14142'
|
|
},
|
|
'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'
|
|
},
|
|
'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:8082',
|
|
prod: 'http://localhost:8082'
|
|
},
|
|
'onlyoffice': {
|
|
dev: 'http://localhost:8083',
|
|
prod: 'http://localhost:8083'
|
|
},
|
|
'penpot': {
|
|
dev: 'http://localhost:9001',
|
|
prod: 'http://localhost:9001'
|
|
}
|
|
}
|
|
|
|
if (appUrls[id]) {
|
|
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
|
// Replace localhost with current hostname for remote access
|
|
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) {
|
|
// In development, just alert - in production would open the actual interface
|
|
alert(`Would launch ${pkg.value.manifest.title} interface`)
|
|
}
|
|
}
|
|
|
|
async function startApp() {
|
|
try {
|
|
await store.startPackage(appId.value)
|
|
} catch (err) {
|
|
console.error('Failed to start app:', err)
|
|
}
|
|
}
|
|
|
|
async function stopApp() {
|
|
try {
|
|
await store.stopPackage(appId.value)
|
|
} catch (err) {
|
|
console.error('Failed to stop app:', err)
|
|
}
|
|
}
|
|
|
|
async function restartApp() {
|
|
try {
|
|
await store.restartPackage(appId.value)
|
|
} catch (err) {
|
|
console.error('Failed to restart app:', err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
// Navigate back to apps after uninstall
|
|
router.push('/dashboard/apps')
|
|
} catch (err) {
|
|
console.error('Failed to uninstall app:', err)
|
|
alert('Failed to uninstall app')
|
|
}
|
|
}
|
|
|
|
// Keep for backwards compatibility but redirect to modal
|
|
async function uninstallApp() {
|
|
showUninstallModal()
|
|
}
|
|
|
|
function getStatusClass(state: PackageState): string {
|
|
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.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): string {
|
|
switch (state) {
|
|
case PackageState.Running:
|
|
return 'bg-green-400'
|
|
case PackageState.Stopped:
|
|
return 'bg-gray-400'
|
|
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'
|
|
}
|
|
}
|
|
</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;
|
|
}
|
|
</style>
|