2026-01-24 22:59:20 +00:00
< template >
< div class = "pb-6" >
2026-03-17 00:34:37 +00:00
< div class = "hidden md:block mb-8" >
< h1 class = "text-3xl font-bold text-white mb-2" > { { t ( 'apps.title' ) } } < / h1 >
< p class = "text-white/70" > { { t ( 'apps.subtitle' ) } } < / p >
2026-01-24 22:59:20 +00:00
< / div >
2026-03-17 00:34:37 +00:00
<!-- Search Bar -- >
< div class = "mb-4" >
2026-03-05 08:06:07 +00:00
< input
v - model = "searchQuery"
type = "text"
2026-03-11 13:45:59 +00:00
: placeholder = "t('apps.searchPlaceholder')"
: aria - label = "t('apps.searchLabel')"
2026-03-17 00:34:37 +00:00
class = "w-full px-4 py-3 md: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"
2026-03-05 08:06:07 +00:00
/ >
< / div >
2026-03-11 17:33:42 +00:00
<!-- 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 >
2026-03-11 13:04:31 +00:00
<!-- Empty State -- >
2026-03-11 17:33:42 +00:00
< div v -else -if = " sortedPackageEntries.length = = = 0 & & ! searchQuery " class = "text-center py-16 pb-6" >
2026-01-24 22:59:20 +00:00
< 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 >
2026-03-11 13:45:59 +00:00
< 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 >
2026-01-24 22:59:20 +00:00
< 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"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'apps.browseAppStore' ) } }
2026-01-24 22:59:20 +00:00
< / RouterLink >
< / div >
< / div >
2026-03-05 08:06:07 +00:00
<!-- No Results -- >
< div v-if = "filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12" >
2026-03-11 13:45:59 +00:00
< p class = "text-white/70" > { { t ( 'apps.noResults' , { query : searchQuery } ) } } < / p >
2026-03-05 08:06:07 +00:00
< / div >
2026-01-27 22:47:51 +00:00
<!-- Apps Grid ( alphabetically by title , stable across run state ) -- >
2026-01-24 22:59:20 +00:00
< div class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6" >
< div
2026-03-09 07:43:12 +00:00
v - for = "([id, pkg], index) in filteredPackageEntries"
2026-01-24 22:59:20 +00:00
: key = "id"
2026-02-17 20:40:26 +00:00
data - controller - container
2026-02-17 22:10:38 +00:00
: data - controller - launch = "canLaunch(pkg) ? '' : undefined"
2026-02-17 20:40:26 +00:00
tabindex = "0"
2026-03-11 13:11:45 +00:00
role = "link"
2026-03-09 07:43:12 +00:00
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 }"
2026-01-24 22:59:20 +00:00
@ click = "goToApp(id as string)"
2026-03-11 13:11:45 +00:00
@ keydown . enter = "goToApp(id as string)"
2026-01-24 22:59:20 +00:00
>
2026-03-12 00:19:30 +00:00
<!-- Uninstall Icon ( not for web - only apps ) -- >
2026-01-24 22:59:20 +00:00
< button
2026-03-12 00:19:30 +00:00
v - if = "!isWebOnlyApp(id as string)"
2026-01-24 22:59:20 +00:00
@ 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"
2026-03-11 13:45:59 +00:00
: aria - label = "`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
: title = "t('common.uninstall')"
2026-01-24 22:59:20 +00:00
>
2026-03-11 13:04:31 +00:00
< svg class = "w-5 h-5" aria -hidden = " true " fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
2026-01-24 22:59:20 +00:00
< 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
2026-03-09 17:09:59 +00:00
: src = "pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
: alt = "pkg.manifest?.title || String(id)"
2026-01-24 22:59:20 +00:00
class = "w-16 h-16 rounded-lg object-cover bg-white/10"
@ error = "handleImageError"
/ >
2026-02-17 15:03:34 +00:00
< div class = "flex-1 min-w-0 overflow-hidden" >
< h3 class = "text-lg font-semibold text-white mb-1 truncate" :title = "pkg.manifest.title" >
2026-01-24 22:59:20 +00:00
{ { pkg . manifest . title } }
< / h3 >
< p class = "text-sm text-white/70 mb-2 truncate" >
2026-03-09 17:09:59 +00:00
{ { pkg . manifest ? . description ? . short || '' } }
2026-01-24 22:59:20 +00:00
< / p >
< div class = "flex items-center gap-2" >
< span
class = "inline-flex items-center px-2 py-1 rounded text-xs font-medium"
: class = "getStatusClass(pkg.state)"
>
{ { 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)"
2026-02-17 22:10:38 +00:00
data - controller - launch - btn
2026-01-24 22:59:20 +00:00
@ click . stop = "launchApp(id as string)"
2026-03-04 05:23:42 +00:00
class = "flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
2026-01-24 22:59:20 +00:00
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.launch' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< button
2026-03-12 00:19:30 +00:00
v - if = "!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
2026-01-24 22:59:20 +00:00
@ click . stop = "startApp(id as string)"
2026-02-01 13:24:03 +00:00
: 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"
2026-01-24 22:59:20 +00:00
>
2026-02-01 13:24:03 +00:00
< svg
v - if = "loadingActions[id as string]"
class = "animate-spin h-4 w-4"
2026-03-11 13:04:31 +00:00
aria - hidden = "true"
2026-02-01 13:24:03 +00:00
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 >
2026-03-11 13:45:59 +00:00
< span > { { loadingActions [ id as string ] ? t ( 'common.starting' ) : t ( 'common.start' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / button >
< button
2026-03-12 00:19:30 +00:00
v - if = "!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
2026-01-24 22:59:20 +00:00
@ click . stop = "stopApp(id as string)"
2026-02-01 13:24:03 +00:00
: 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"
2026-01-24 22:59:20 +00:00
>
2026-02-01 13:24:03 +00:00
< svg
v - if = "loadingActions[id as string]"
class = "animate-spin h-4 w-4"
2026-03-11 13:04:31 +00:00
aria - hidden = "true"
2026-02-01 13:24:03 +00:00
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 >
2026-03-11 13:45:59 +00:00
< span > { { loadingActions [ id as string ] ? t ( 'common.stopping' ) : t ( 'common.stop' ) } } < / span >
2026-01-24 22:59:20 +00:00
< / 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"
2026-02-17 22:10:38 +00:00
@ click = "closeUninstallModal()"
2026-01-24 22:59:20 +00:00
>
2026-03-17 00:34:37 +00:00
< div class = "absolute inset-0 bg-black/60 backdrop-blur-sm" > < / div >
2026-01-24 22:59:20 +00:00
< div
2026-02-17 22:10:38 +00:00
ref = "uninstallModalRef"
2026-01-24 22:59:20 +00:00
@ click . stop
2026-03-11 13:04:31 +00:00
role = "dialog"
aria - modal = "true"
aria - labelledby = "uninstall-dialog-title"
2026-03-17 00:34:37 +00:00
class = "glass-card p-6 max-w-md w-full relative z-10"
2026-01-24 22:59:20 +00:00
>
< div class = "flex items-start gap-4 mb-4" >
< div class = "p-3 bg-red-500/20 rounded-lg" >
2026-03-11 13:04:31 +00:00
< svg class = "w-6 h-6 text-red-400" aria -hidden = " true " fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
2026-01-24 22:59:20 +00:00
< 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" >
2026-03-11 13:45:59 +00:00
< h3 id = "uninstall-dialog-title" class = "text-xl font-semibold text-white mb-2" > { { t ( 'apps.uninstallTitle' ) } } < / h3 >
2026-01-24 22:59:20 +00:00
< p class = "text-white/70" >
2026-03-11 13:45:59 +00:00
{ { t ( 'apps.uninstallConfirm' , { name : uninstallModal . appTitle } ) } }
2026-01-24 22:59:20 +00:00
< / p >
< / div >
< / div >
< div class = "flex gap-3 justify-end" >
< button
2026-02-17 22:10:38 +00:00
@ click = "closeUninstallModal()"
2026-01-24 22:59:20 +00:00
class = "px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
2026-03-11 13:45:59 +00:00
{ { t ( 'common.cancel' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< button
@ click = "confirmUninstall"
2026-03-17 00:34:37 +00:00
class = "px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
2026-01-24 22:59:20 +00:00
>
2026-03-17 00:34:37 +00:00
{ { t ( 'common.uninstall' ) } }
2026-01-24 22:59:20 +00:00
< / button >
< / div >
< / div >
< / div >
< / Transition >
2026-03-11 13:04:31 +00:00
<!-- 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 >
2026-03-11 13:45:59 +00:00
< button @ click = "actionError = ''" :aria-label = "t('apps.dismissError')" class = "text-red-300 hover:text-white shrink-0" > & times ; < / button >
2026-03-11 13:04:31 +00:00
< / div >
< / div >
< / Transition >
2026-01-24 22:59:20 +00:00
< / div >
< / template >
< script setup lang = "ts" >
2026-03-01 18:07:35 +00:00
import { computed , ref , onBeforeUnmount } from 'vue'
2026-01-24 22:59:20 +00:00
import { useRouter , RouterLink } from 'vue-router'
2026-03-11 13:45:59 +00:00
import { useI18n } from 'vue-i18n'
2026-01-24 22:59:20 +00:00
import { useAppStore } from '../stores/app'
2026-03-11 13:45:59 +00:00
const { t } = useI18n ( )
2026-02-17 21:10:16 +00:00
import { useAppLauncherStore } from '../stores/appLauncher'
2026-03-11 13:04:31 +00:00
import { PackageState , type PackageDataEntry } from '../types/api'
2026-02-17 22:10:38 +00:00
import { useModalKeyboard } from '@/composables/useModalKeyboard'
2026-01-24 22:59:20 +00:00
const router = useRouter ( )
const store = useAppStore ( )
2026-03-05 08:06:07 +00:00
// Search
const searchQuery = ref ( '' )
2026-02-01 13:24:03 +00:00
// Track loading states for each app action
const loadingActions = ref < Record < string , boolean > > ( { } )
2026-03-11 13:04:31 +00:00
// 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 )
}
2026-03-12 00:19:30 +00:00
// Web-only app IDs and their URLs
const WEB _ONLY _APP _URLS : Record < string , string > = {
2026-03-17 00:34:37 +00:00
'indeedhub' : ` ${ window . location . protocol } // ${ window . location . hostname } :7777 ` ,
2026-03-12 00:19:30 +00:00
'botfights' : 'https://botfights.net' ,
'nwnn' : 'https://nwnn.l484.com' ,
'484-kitchen' : 'https://484.kitchen' ,
'call-the-operator' : 'https://cta.tx1138.com' ,
2026-03-17 00:34:37 +00:00
'arch-presentation' : 'https://present.l484.com' ,
2026-03-12 00:19:30 +00:00
'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 > = {
2026-03-17 00:34:37 +00:00
'indeedhub' : {
state : 'running' as PackageState ,
manifest : { id : 'indeedhub' , title : 'Indeehub' , version : '0.1.0' , description : { short : 'Bitcoin documentary streaming 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/indeehub.ico' } ,
} ,
2026-03-12 00:19:30 +00:00
'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' : {
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' } ,
2026-03-17 00:34:37 +00:00
} ,
2026-03-12 00:19:30 +00:00
'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
2026-01-24 22:59:20 +00:00
const packages = computed ( ( ) => {
2026-03-12 00:19:30 +00:00
const realPackages = store . packages || { }
return { ... WEB _ONLY _APPS , ... realPackages }
2026-01-24 22:59:20 +00:00
} )
2026-03-12 00:19:30 +00:00
// Web-only apps first (alphabetically), then all other apps (alphabetically)
2026-01-27 22:47:51 +00:00
const sortedPackageEntries = computed ( ( ) => {
const entries = Object . entries ( packages . value )
2026-03-17 00:34:37 +00:00
return entries . sort ( ( [ idA , a ] , [ idB , b ] ) => {
2026-03-12 00:19:30 +00:00
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' } )
} )
2026-01-27 22:47:51 +00:00
} )
2026-03-05 08:06:07 +00:00
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 )
)
} )
2026-01-24 22:59:20 +00:00
const uninstallModal = ref ( {
show : false ,
appId : '' ,
appTitle : ''
} )
2026-02-17 22:10:38 +00:00
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 }
)
2026-01-24 22:59:20 +00:00
2026-03-11 13:04:31 +00:00
function canLaunch ( pkg : PackageDataEntry ) : boolean {
2026-03-12 00:19:30 +00:00
// Web-only apps are always launchable
if ( isWebOnlyApp ( pkg . manifest . id ) ) return true
2026-01-24 22:59:20 +00:00
// For real apps, check for UI interface
const hasUI = pkg . manifest . interfaces ? . main ? . ui || pkg . installed ? . [ 'interface-addresses' ] ? . main
2026-02-14 16:44:20 +00:00
const canLaunchState = pkg . state === 'running' || pkg . state === 'starting'
2026-03-11 13:04:31 +00:00
return ! ! hasUI && canLaunchState
2026-01-24 22:59:20 +00:00
}
function launchApp ( id : string ) {
2026-03-17 00:34:37 +00:00
const isDev = import . meta . env . DEV
const pkg = packages . value [ id ]
// Web-only apps — use their external URL directly
const webOnlyUrl = WEB _ONLY _APP _URLS [ id ]
if ( webOnlyUrl ) {
useAppLauncherStore ( ) . open ( { url : webOnlyUrl , title : pkg ? . manifest ? . title || id } )
return
}
// Explicit URLs for apps that need them (checked first to avoid package data issues)
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
} ,
}
if ( appUrls [ id ] ) {
let url = isDev ? appUrls [ id ] . dev : appUrls [ id ] . prod
// Replace localhost with current hostname for remote access (not for external IPs like LoraBell)
if ( url . includes ( 'localhost' ) ) {
const currentHost = window . location . hostname
url = url . replace ( 'localhost' , currentHost )
}
useAppLauncherStore ( ) . open ( { url , title : pkg ? . manifest ? . title || id } )
return
}
// Get the LAN address from the package
let lanAddress = pkg ? . installed ? . [ 'interface-addresses' ] ? . main ? . [ 'lan-address' ]
// Replace localhost with the current hostname (for remote access)
if ( lanAddress && lanAddress . includes ( 'localhost' ) ) {
const currentHost = window . location . hostname
lanAddress = lanAddress . replace ( 'localhost' , currentHost )
}
if ( lanAddress ) {
useAppLauncherStore ( ) . open ( { url : lanAddress , title : pkg ? . manifest ? . title || id } )
return
}
// For other apps, navigate to app details which has launch functionality
router . push ( ` /dashboard/apps/ ${ id } ` ) . catch ( ( ) => { } )
2026-01-24 22:59:20 +00:00
}
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 ) {
2026-03-01 18:07:35 +00:00
router . push ( ` /dashboard/apps/ ${ id } ` ) . catch ( ( ) => { } )
2026-01-24 22:59:20 +00:00
}
2026-03-01 18:07:35 +00:00
const actionTimers = new Map < string , ReturnType < typeof setTimeout > > ( )
2026-01-24 22:59:20 +00:00
async function startApp ( id : string ) {
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = true
2026-01-24 22:59:20 +00:00
try {
await store . startPackage ( id )
2026-03-01 18:07:35 +00:00
if ( actionTimers . has ( id ) ) clearTimeout ( actionTimers . get ( id ) ! )
actionTimers . set ( id , setTimeout ( ( ) => {
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = false
2026-03-01 18:07:35 +00:00
actionTimers . delete ( id )
} , 5000 ) )
2026-01-24 22:59:20 +00:00
} catch ( err ) {
2026-03-11 13:04:31 +00:00
if ( import . meta . env . DEV ) console . error ( 'Failed to start app:' , err )
showActionError ( ` Failed to start app: ${ err instanceof Error ? err . message : 'Unknown error' } ` )
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = false
2026-01-24 22:59:20 +00:00
}
}
async function stopApp ( id : string ) {
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = true
2026-01-24 22:59:20 +00:00
try {
await store . stopPackage ( id )
2026-03-01 18:07:35 +00:00
if ( actionTimers . has ( id ) ) clearTimeout ( actionTimers . get ( id ) ! )
actionTimers . set ( id , setTimeout ( ( ) => {
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = false
2026-03-01 18:07:35 +00:00
actionTimers . delete ( id )
} , 5000 ) )
2026-01-24 22:59:20 +00:00
} catch ( err ) {
2026-03-11 13:04:31 +00:00
if ( import . meta . env . DEV ) console . error ( 'Failed to stop app:' , err )
showActionError ( ` Failed to stop app: ${ err instanceof Error ? err . message : 'Unknown error' } ` )
2026-02-01 13:24:03 +00:00
loadingActions . value [ id ] = false
2026-01-24 22:59:20 +00:00
}
}
2026-03-01 18:07:35 +00:00
onBeforeUnmount ( ( ) => {
for ( const t of actionTimers . values ( ) ) clearTimeout ( t )
actionTimers . clear ( )
} )
2026-01-24 22:59:20 +00:00
2026-03-11 13:04:31 +00:00
function showUninstallModal ( id : string , pkg : PackageDataEntry ) {
2026-01-24 22:59:20 +00:00
uninstallModal . value = {
show : true ,
appId : id ,
appTitle : pkg . manifest . title
}
}
async function confirmUninstall ( ) {
const { appId } = uninstallModal . value
2026-03-17 00:34:37 +00:00
uninstallModal . value . show = false
2026-01-24 22:59:20 +00:00
try {
await store . uninstallPackage ( appId )
} catch ( err ) {
2026-03-11 13:04:31 +00:00
if ( import . meta . env . DEV ) console . error ( 'Failed to uninstall app:' , err )
showActionError ( ` Failed to uninstall app: ${ err instanceof Error ? err . message : 'Unknown error' } ` )
2026-01-24 22:59:20 +00:00
}
}
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.3 s ease ;
}
. modal - enter - active . glass - card ,
. modal - leave - active . glass - card {
transition : transform 0.3 s ease , opacity 0.3 s 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 >