My Apps/App Store/Services tabs, category filters, and search bar now stay fixed at the top on scroll using sticky positioning with glass-blur background. Applied to both Apps.vue and Marketplace.vue desktop views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
628 lines
27 KiB
Vue
628 lines
27 KiB
Vue
<template>
|
|
<div class="pb-6">
|
|
<!-- Desktop: page tabs + category tabs + search (sticky on scroll) -->
|
|
<div class="hidden md:flex mb-4 items-center gap-4 sticky top-0 z-10 py-3 -mt-3 -mx-6 px-6" style="background: rgba(0,0,0,0.6); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
|
<div class="mode-switcher flex-shrink-0">
|
|
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
|
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
|
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
|
</div>
|
|
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
|
|
<button
|
|
v-for="category in categoriesWithApps"
|
|
:key="category.id"
|
|
@click="selectedCategory = category.id"
|
|
class="mode-switcher-btn"
|
|
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
|
>{{ category.name }}</button>
|
|
</div>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('apps.searchPlaceholder')"
|
|
:aria-label="t('apps.searchLabel')"
|
|
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
|
|
<div class="md:hidden mb-4">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('apps.searchPlaceholder')"
|
|
:aria-label="t('apps.searchLabel')"
|
|
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Loading Skeleton -->
|
|
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
|
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
|
|
<div class="flex items-start gap-4">
|
|
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
|
|
<div class="flex-1">
|
|
<div class="h-5 w-32 bg-white/10 rounded mb-2"></div>
|
|
<div class="h-4 w-48 bg-white/5 rounded mb-3"></div>
|
|
<div class="h-6 w-20 bg-white/5 rounded"></div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex gap-2">
|
|
<div class="flex-1 h-9 bg-white/5 rounded-lg"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
|
<div class="glass-card p-12 max-w-md mx-auto">
|
|
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
|
</svg>
|
|
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
|
|
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
|
|
<RouterLink
|
|
to="/dashboard/marketplace"
|
|
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
|
>
|
|
{{ t('apps.browseAppStore') }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No Results -->
|
|
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
|
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
|
</div>
|
|
|
|
<!-- Apps Grid (alphabetically by title, stable across run state) -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
|
<div
|
|
v-for="([id, pkg], index) in filteredPackageEntries"
|
|
:key="id"
|
|
data-controller-container
|
|
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
|
tabindex="0"
|
|
role="link"
|
|
class="glass-card card-stagger p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
|
:style="{ '--stagger-index': index }"
|
|
@click="goToApp(id as string)"
|
|
@keydown.enter="goToApp(id as string)"
|
|
>
|
|
<!-- Uninstall Icon (not for web-only apps) -->
|
|
<button
|
|
v-if="!isWebOnlyApp(id as string)"
|
|
@click.stop="showUninstallModal(id as string, pkg)"
|
|
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
|
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
|
:title="t('common.uninstall')"
|
|
>
|
|
<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="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 class="flex items-start gap-4">
|
|
<img
|
|
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
|
|
:alt="pkg.manifest?.title || String(id)"
|
|
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
|
@error="handleImageError"
|
|
/>
|
|
<div class="flex-1 min-w-0 overflow-hidden">
|
|
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
|
{{ pkg.manifest.title }}
|
|
</h3>
|
|
<p class="text-sm text-white/70 mb-2 truncate">
|
|
{{ pkg.manifest?.description?.short || '' }}
|
|
</p>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
|
|
:class="getStatusClass(pkg.state)"
|
|
>
|
|
<svg
|
|
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
|
|
class="animate-spin h-3 w-3"
|
|
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>
|
|
{{ pkg.state }}
|
|
</span>
|
|
<span class="text-xs text-white/50">
|
|
v{{ pkg.manifest.version }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="mt-4 flex gap-2">
|
|
<button
|
|
v-if="canLaunch(pkg)"
|
|
data-controller-launch-btn
|
|
@click.stop="launchApp(id as string)"
|
|
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.launch') }}
|
|
</button>
|
|
<button
|
|
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
|
@click.stop="startApp(id as string)"
|
|
:disabled="loadingActions[id as string]"
|
|
class="flex-1 px-4 py-2 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 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<svg
|
|
v-if="loadingActions[id as string]"
|
|
class="animate-spin h-4 w-4"
|
|
aria-hidden="true"
|
|
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>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
|
|
</button>
|
|
<button
|
|
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
|
|
@click.stop="stopApp(id as string)"
|
|
:disabled="loadingActions[id as string]"
|
|
class="flex-1 px-4 py-2 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 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<svg
|
|
v-if="loadingActions[id as string]"
|
|
class="animate-spin h-4 w-4"
|
|
aria-hidden="true"
|
|
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>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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-md"></div>
|
|
<div
|
|
ref="uninstallModalRef"
|
|
@click.stop
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="uninstall-dialog-title"
|
|
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
|
>
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<div class="p-3 bg-red-500/20 rounded-lg">
|
|
<svg class="w-6 h-6 text-red-400" aria-hidden="true" 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">
|
|
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
|
<p class="text-white/70">
|
|
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end">
|
|
<button
|
|
@click="closeUninstallModal()"
|
|
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
@click="confirmUninstall"
|
|
:disabled="uninstalling"
|
|
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<svg
|
|
v-if="uninstalling"
|
|
class="animate-spin h-4 w-4"
|
|
aria-hidden="true"
|
|
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>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
|
|
|
|
<!-- 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="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 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 { computed, ref, onBeforeUnmount } from 'vue'
|
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '../stores/app'
|
|
|
|
const { t } = useI18n()
|
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
|
import { PackageState, type PackageDataEntry } from '../types/api'
|
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const store = useAppStore()
|
|
|
|
// Tabs — support ?tab=services from Marketplace link
|
|
const activeTab = ref<'apps' | 'services'>(
|
|
route.query.tab === 'services' ? 'services' : 'apps'
|
|
)
|
|
|
|
|
|
// Service container name patterns (backend/infra, not user-facing)
|
|
const SERVICE_NAMES = new Set([
|
|
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
|
'immich_postgres', 'immich_redis',
|
|
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
|
|
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
|
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
|
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
|
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
|
'mysql-mempool',
|
|
])
|
|
|
|
function isServiceContainer(id: string): boolean {
|
|
if (SERVICE_NAMES.has(id)) return true
|
|
// Catch any indeedhub-build_* compose infrastructure containers
|
|
if (id.startsWith('indeedhub-build_')) return true
|
|
// Catch database containers
|
|
if (id.endsWith('_db') || id.endsWith('-db')) return true
|
|
return false
|
|
}
|
|
|
|
// Search
|
|
const searchQuery = ref('')
|
|
|
|
// Category filter (same categories as App Store)
|
|
const selectedCategory = ref('all')
|
|
|
|
// Known app → category mappings (matches App Store categorisation)
|
|
const APP_CATEGORY_MAP: Record<string, string> = {
|
|
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
|
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
|
'fedimint': 'money', 'fedimint-gateway': 'money',
|
|
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
|
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'onlyoffice': 'data',
|
|
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
|
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
|
|
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
|
|
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
|
'uptime-kuma': 'networking', 'dwn': 'data',
|
|
'botfights': 'l484', 'nwnn': 'l484', '484-kitchen': 'l484',
|
|
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
|
}
|
|
|
|
function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
|
// Check hardcoded map first, then manifest category, then fallback
|
|
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
|
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
|
return cat || 'other'
|
|
}
|
|
|
|
const ALL_CATEGORIES = computed(() => [
|
|
{ id: 'all', name: t('marketplace.all') },
|
|
{ id: 'community', name: t('marketplace.community') },
|
|
{ id: 'nostr', name: 'Nostr' },
|
|
{ id: 'commerce', name: t('marketplace.commerce') },
|
|
{ id: 'money', name: t('marketplace.money') },
|
|
{ id: 'data', name: t('marketplace.data') },
|
|
{ id: 'media', name: 'Media' },
|
|
{ id: 'home', name: t('marketplace.homeCategory') },
|
|
{ id: 'networking', name: t('marketplace.networking') },
|
|
{ id: 'l484', name: 'L484' },
|
|
{ id: 'other', name: t('marketplace.other') },
|
|
])
|
|
|
|
const categoriesWithApps = computed(() => {
|
|
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
|
|
return ALL_CATEGORIES.value.filter(cat => {
|
|
if (cat.id === 'all') return true
|
|
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
|
})
|
|
})
|
|
|
|
|
|
// Track loading states for each app action
|
|
const loadingActions = ref<Record<string, boolean>>({})
|
|
|
|
// Action error toast
|
|
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)
|
|
}
|
|
|
|
// Web-only app IDs and their URLs
|
|
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
|
'botfights': 'https://botfights.net',
|
|
'nwnn': 'https://nwnn.l484.com',
|
|
'484-kitchen': 'https://484.kitchen',
|
|
'call-the-operator': 'https://cta.tx1138.com',
|
|
// 'arch-presentation': hidden until X-Frame-Options fixed
|
|
'syntropy-institute': 'https://syntropy.institute',
|
|
't-zero': 'https://teeminuszero.net',
|
|
}
|
|
|
|
function isWebOnlyApp(id: string): boolean {
|
|
return id in WEB_ONLY_APP_URLS
|
|
}
|
|
|
|
// Web-only apps (no container) — always show as installed bookmarks
|
|
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
|
'botfights': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/botfights.svg' },
|
|
},
|
|
'nwnn': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
|
|
},
|
|
'484-kitchen': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
|
|
},
|
|
'call-the-operator': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
|
},
|
|
/* arch-presentation hidden until X-Frame-Options fixed
|
|
'arch-presentation': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
|
}, */
|
|
'syntropy-institute': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
|
|
},
|
|
't-zero': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
|
|
},
|
|
}
|
|
|
|
// Merge real packages from store with web-only app bookmarks
|
|
const packages = computed(() => {
|
|
const realPackages = store.packages || {}
|
|
return { ...WEB_ONLY_APPS, ...realPackages }
|
|
})
|
|
|
|
// Web-only apps first (alphabetically), then all other apps (alphabetically)
|
|
const sortedPackageEntries = computed(() => {
|
|
const entries = Object.entries(packages.value)
|
|
// Filter by active tab and category
|
|
const filtered = entries.filter(([id, pkg]) => {
|
|
const isSvc = isServiceContainer(id)
|
|
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
|
|
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
|
|
return getAppCategory(id, pkg) === selectedCategory.value
|
|
}
|
|
return true
|
|
})
|
|
return filtered.sort(([idA, a], [idB, b]) => {
|
|
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
|
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
|
if (aWeb !== bWeb) return aWeb - bWeb
|
|
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
|
|
})
|
|
})
|
|
|
|
const filteredPackageEntries = computed(() => {
|
|
if (!searchQuery.value) return sortedPackageEntries.value
|
|
const q = searchQuery.value.toLowerCase()
|
|
return sortedPackageEntries.value.filter(([id, pkg]) =>
|
|
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
|
|
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
|
|
id.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
const uninstallModal = ref({
|
|
show: false,
|
|
appId: '',
|
|
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 }
|
|
)
|
|
|
|
function canLaunch(pkg: PackageDataEntry): boolean {
|
|
// Web-only apps are always launchable
|
|
if (isWebOnlyApp(pkg.manifest.id)) return true
|
|
// For real apps, check for UI interface
|
|
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
|
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
|
|
return !!hasUI && canLaunchState
|
|
}
|
|
|
|
function launchApp(id: string) {
|
|
useAppLauncherStore().openSession(id)
|
|
}
|
|
|
|
function getStatusClass(state: PackageState): string {
|
|
switch (state) {
|
|
case PackageState.Running:
|
|
return 'bg-green-500/20 text-green-200'
|
|
case PackageState.Stopped:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
case PackageState.Starting:
|
|
case PackageState.Stopping:
|
|
case PackageState.Restarting:
|
|
return 'bg-yellow-500/20 text-yellow-200'
|
|
case PackageState.Installing:
|
|
return 'bg-blue-500/20 text-blue-200'
|
|
default:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
}
|
|
}
|
|
|
|
function goToApp(id: string) {
|
|
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
|
}
|
|
|
|
const actionTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
async function startApp(id: string) {
|
|
loadingActions.value[id] = true
|
|
try {
|
|
await store.startPackage(id)
|
|
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
|
actionTimers.set(id, setTimeout(() => {
|
|
loadingActions.value[id] = false
|
|
actionTimers.delete(id)
|
|
}, 5000))
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Failed to start app:', err)
|
|
showActionError(`Failed to start app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
loadingActions.value[id] = false
|
|
}
|
|
}
|
|
|
|
async function stopApp(id: string) {
|
|
loadingActions.value[id] = true
|
|
try {
|
|
await store.stopPackage(id)
|
|
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
|
actionTimers.set(id, setTimeout(() => {
|
|
loadingActions.value[id] = false
|
|
actionTimers.delete(id)
|
|
}, 5000))
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
|
|
showActionError(`Failed to stop app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
loadingActions.value[id] = false
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
for (const t of actionTimers.values()) clearTimeout(t)
|
|
actionTimers.clear()
|
|
})
|
|
|
|
|
|
function showUninstallModal(id: string, pkg: PackageDataEntry) {
|
|
uninstallModal.value = {
|
|
show: true,
|
|
appId: id,
|
|
appTitle: pkg.manifest.title
|
|
}
|
|
}
|
|
|
|
const uninstalling = ref(false)
|
|
|
|
async function confirmUninstall() {
|
|
const { appId } = uninstallModal.value
|
|
uninstalling.value = true
|
|
|
|
try {
|
|
await store.uninstallPackage(appId)
|
|
uninstallModal.value.show = false
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
|
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
uninstallModal.value.show = false
|
|
} finally {
|
|
uninstalling.value = false
|
|
}
|
|
}
|
|
|
|
function handleImageError(e: Event) {
|
|
const target = e.target as HTMLImageElement
|
|
const currentSrc = target.src
|
|
|
|
// Try fallback icon - use a simple placeholder SVG
|
|
// Create a data URI for a simple icon placeholder
|
|
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
|
|
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
|
|
<path d="M20 44H44V48H20V44Z" 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
|
|
}
|
|
}
|
|
|
|
</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>
|