Refactor app navigation and enhance background handling in various views

- Added error handling for router navigation to prevent unhandled promise rejections.
- Improved background image management in Dashboard.vue to dynamically set images based on route.
- Introduced timers for managing loading states and cleanup on component unmount in Apps.vue, Marketplace.vue, and other views.
- Updated app detail navigation to ensure smoother transitions and error handling.
- Enhanced clipboard copy functionality in Settings.vue with improved user feedback.
This commit is contained in:
Dorian
2026-03-01 18:07:35 +00:00
parent 7a05e11834
commit 94eb1e4283
7 changed files with 218 additions and 399 deletions

View File

@@ -621,6 +621,7 @@ function goBack() {
router.back()
}
function launchApp() {
if (!pkg.value) return
@@ -771,8 +772,7 @@ async function confirmUninstall() {
try {
await store.uninstallPackage(appId.value)
// Navigate back to apps after uninstall
router.push('/dashboard/apps')
router.push('/dashboard/apps').catch(() => {})
} catch (err) {
console.error('Failed to uninstall app:', err)
alert('Failed to uninstall app')

View File

@@ -171,7 +171,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
@@ -187,7 +187,7 @@ const loadingActions = ref<Record<string, boolean>>({})
// Use real packages from store - no more dummy apps
const packages = computed(() => {
const realPackages = store.packages
console.log('[Apps] Real packages from store:', Object.keys(realPackages || {}).length, 'apps')
if (import.meta.env.DEV) console.log('[Apps] Real packages from store:', Object.keys(realPackages || {}).length, 'apps')
return realPackages || {}
})
@@ -276,7 +276,7 @@ function launchApp(id: string) {
}
// For other apps, navigate to app details which has launch functionality
router.push(`/dashboard/apps/${id}`)
router.push(`/dashboard/apps/${id}`).catch(() => {})
}
function getStatusClass(state: PackageState): string {
@@ -297,19 +297,20 @@ function getStatusClass(state: PackageState): string {
}
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`)
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)
// Wait for state update from WebSocket
// The loader will be cleared when we receive the updated state
// For now, keep a max timeout as fallback
setTimeout(() => {
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
}, 5000)
actionTimers.delete(id)
}, 5000))
} catch (err) {
console.error('Failed to start app:', err)
loadingActions.value[id] = false
@@ -320,18 +321,22 @@ async function stopApp(id: string) {
loadingActions.value[id] = true
try {
await store.stopPackage(id)
// Wait for state update from WebSocket
// The loader will be cleared when we receive the updated state
// For now, keep a max timeout as fallback
setTimeout(() => {
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
}, 5000)
actionTimers.delete(id)
}, 5000))
} catch (err) {
console.error('Failed to stop app:', err)
loadingActions.value[id] = false
}
}
onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear()
})
// @ts-ignore - Function kept for future use
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -2,33 +2,33 @@
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container">
<!-- Background - default (zoom animates on this layer, not container, to avoid letterboxing) -->
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
<div
ref="bgDefault"
class="bg-layer bg-fullwidth"
:class="[
{ 'bg-transitioning-out': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground },
{ 'bg-transitioning-out': showAltBackground },
{ 'zoom-reveal-bg': showZoomIn }
]"
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<!-- Background - alternate for app details and Web5 -->
<!-- Background - detail layer (only visible during app/marketplace detail 3D transition) -->
<div
ref="bgAlt"
class="bg-layer bg-fullwidth"
:class="{ 'bg-transitioning-in': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }"
:class="{ 'bg-transitioning-in': showAltBackground }"
style="background-image: url(/assets/img/bg-intro-3.jpg)"
/>
<!-- Glitch overlays - trigger on background change -->
<div
class="bg-glitch-layer-1"
:class="{ 'glitch-active': isGlitching }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div
class="bg-glitch-layer-2"
:class="{ 'glitch-active': isGlitching }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div
class="bg-glitch-scan"
@@ -37,11 +37,11 @@
<!-- Continuous glitch/flash overlays - same as login, every 5s -->
<div
class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div
class="dashboard-glitch-layer dashboard-glitch-2 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div class="dashboard-glitch-scan" />
</div>
@@ -319,52 +319,61 @@ const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const showZoomIn = ref(false)
const pendingTimers: ReturnType<typeof setTimeout>[] = []
function scheduledTimeout(fn: () => void, ms: number) {
const id = setTimeout(fn, ms)
pendingTimers.push(id)
return id
}
function isDetailRoute(path: string) {
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
}
// Background swap for app details, Web5, Network, Settings, My Apps, App Store, Cloud, and Home
const showAltBackground = ref(false)
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
const showHomeBackground = ref(route.path === '/dashboard' || route.path === '/dashboard/')
const showWeb5Background = ref(route.path.includes('/dashboard/web5'))
const showNetworkBackground = ref(route.path.includes('/dashboard/server'))
const showSettingsBackground = ref(route.path.includes('/dashboard/settings'))
const showMyAppsBackground = ref(route.path.includes('/dashboard/apps') && !route.path.includes('/dashboard/apps/'))
const showAppStoreBackground = ref(route.path.includes('/dashboard/marketplace'))
const showCloudBackground = ref(route.path.includes('/dashboard/cloud'))
const showWeb5Overlay = ref(route.path.includes('/dashboard/web5')) // Separate ref for overlay to handle transition delay
const showNetworkOverlay = ref(route.path.includes('/dashboard/server'))
const showSettingsOverlay = ref(route.path.includes('/dashboard/settings'))
const showMyAppsOverlay = ref(route.path.includes('/dashboard/apps') && !route.path.includes('/dashboard/apps/'))
const showAppStoreOverlay = ref(route.path.includes('/dashboard/marketplace'))
const showCloudOverlay = ref(route.path.includes('/dashboard/cloud'))
const isGlitching = ref(false)
// Background images
const currentBackgroundImage = computed(() => {
if (showWeb5Background.value) return 'bg-web5.jpg'
if (showNetworkBackground.value) return 'bg-network.jpg'
if (showSettingsBackground.value) return 'bg-settings.jpg'
if (showMyAppsBackground.value) return 'bg-myapps.jpg'
if (showAppStoreBackground.value) return 'bg-appstore.jpg'
if (showCloudBackground.value) return 'bg-cloud.jpg'
if (showHomeBackground.value) return 'bg-home.jpg'
return 'bg-intro.jpg'
const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard': 'bg-home.jpg',
'/dashboard/': 'bg-home.jpg',
'/dashboard/apps': 'bg-myapps.jpg',
'/dashboard/marketplace': 'bg-appstore.jpg',
'/dashboard/cloud': 'bg-cloud.jpg',
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg',
}
const backgroundImage = computed(() => {
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return ROUTE_BACKGROUNDS[route.path] || 'bg-home.jpg'
})
const altBackgroundImage = computed(() => {
if (showWeb5Background.value) return 'bg-web5.jpg'
if (showNetworkBackground.value) return 'bg-network.jpg'
if (showSettingsBackground.value) return 'bg-settings.jpg'
if (showMyAppsBackground.value) return 'bg-myapps.jpg'
if (showAppStoreBackground.value) return 'bg-appstore.jpg'
if (showCloudBackground.value) return 'bg-cloud.jpg'
if (showHomeBackground.value) return 'bg-home.jpg'
return 'bg-intro-3.jpg'
const isDarkRoute = computed(() => {
const p = route.path
return p.includes('/dashboard/web5') ||
p.includes('/dashboard/server') ||
p.includes('/dashboard/settings') ||
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
p.includes('/dashboard/marketplace') ||
p.includes('/dashboard/cloud')
})
// Check if overlay should be dark (0.8 opacity)
const showDarkOverlay = computed(() => {
return showWeb5Overlay.value || showNetworkOverlay.value || showSettingsOverlay.value || showMyAppsOverlay.value || showAppStoreOverlay.value || showCloudOverlay.value
const showDarkOverlay = ref(isDarkRoute.value)
let overlayTimer: ReturnType<typeof setTimeout> | null = null
watch(isDarkRoute, (dark) => {
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
if (dark) {
showDarkOverlay.value = true
} else {
overlayTimer = scheduledTimeout(() => { showDarkOverlay.value = false }, 450)
}
})
const mobileTabBar = ref<HTMLElement | null>(null)
const appsTabRef = ref<HTMLElement | null>(null)
const marketplaceTabRef = ref<HTMLElement | null>(null)
@@ -376,116 +385,17 @@ const cloudTabRef = ref<HTMLElement | null>(null)
const networkTabIndicatorLeft = ref(0)
const networkTabIndicatorWidth = ref(0)
function isDetailRoute(path: string) {
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
}
watch(() => route.path, (newPath) => {
// Check if we're on app details OR marketplace app details
const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value
// Check if we're on special background routes
const isHome = newPath === '/dashboard' || newPath === '/dashboard/'
const isWeb5 = newPath.includes('/dashboard/web5')
const wasWeb5 = showWeb5Background.value
const isNetwork = newPath.includes('/dashboard/server')
const wasNetwork = showNetworkBackground.value
const isSettings = newPath.includes('/dashboard/settings')
const wasSettings = showSettingsBackground.value
const isMyApps = newPath.includes('/dashboard/apps') && !newPath.includes('/dashboard/apps/')
const wasMyApps = showMyAppsBackground.value
const isAppStore = newPath.includes('/dashboard/marketplace')
const wasAppStore = showAppStoreBackground.value
const isCloud = newPath.includes('/dashboard/cloud')
const wasCloud = showCloudBackground.value
// Change background immediately
showAltBackground.value = isAppDetails
showHomeBackground.value = isHome
showWeb5Background.value = isWeb5
showNetworkBackground.value = isNetwork
showSettingsBackground.value = isSettings
showMyAppsBackground.value = isMyApps
showAppStoreBackground.value = isAppStore
showCloudBackground.value = isCloud
// Handle overlay transitions with delay when leaving special backgrounds
// Web5 overlay
if (isWeb5) {
showWeb5Overlay.value = true
} else if (wasWeb5 && !isWeb5) {
setTimeout(() => {
showWeb5Overlay.value = false
}, 450)
} else {
showWeb5Overlay.value = isWeb5
}
// Network overlay
if (isNetwork) {
showNetworkOverlay.value = true
} else if (wasNetwork && !isNetwork) {
setTimeout(() => {
showNetworkOverlay.value = false
}, 450)
} else {
showNetworkOverlay.value = isNetwork
}
// Settings overlay
if (isSettings) {
showSettingsOverlay.value = true
} else if (wasSettings && !isSettings) {
setTimeout(() => {
showSettingsOverlay.value = false
}, 450)
} else {
showSettingsOverlay.value = isSettings
}
// My Apps overlay
if (isMyApps) {
showMyAppsOverlay.value = true
} else if (wasMyApps && !isMyApps) {
setTimeout(() => {
showMyAppsOverlay.value = false
}, 450)
} else {
showMyAppsOverlay.value = isMyApps
}
// App Store overlay
if (isAppStore) {
showAppStoreOverlay.value = true
} else if (wasAppStore && !isAppStore) {
setTimeout(() => {
showAppStoreOverlay.value = false
}, 450)
} else {
showAppStoreOverlay.value = isAppStore
}
// Cloud overlay
if (isCloud) {
showCloudOverlay.value = true
} else if (wasCloud && !isCloud) {
setTimeout(() => {
showCloudOverlay.value = false
}, 450)
} else {
showCloudOverlay.value = isCloud
}
// Trigger glitch ONLY when going forward (to app details), not back
if (isAppDetails && !wasAppDetails) {
setTimeout(() => {
scheduledTimeout(() => {
isGlitching.value = true
setTimeout(() => {
isGlitching.value = false
}, 375) // Glitch duration - 25% faster
}, 500) // Wait for background 3D transition to complete
scheduledTimeout(() => { isGlitching.value = false }, 375)
}, 500)
}
})
@@ -550,6 +460,12 @@ function updateNetworkTabIndicator() {
networkTabIndicatorWidth.value = tabRect.width
}
function onResize() {
updateTabBarHeight()
updateAppsTabIndicator()
updateNetworkTabIndicator()
}
onMounted(() => {
document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) {
@@ -557,39 +473,30 @@ onMounted(() => {
showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustLoggedIn(false)
// Trigger glitches during background reveal (0.5s, 1.2s, 2s into 2.8s zoom)
const triggerRevealGlitch = () => {
isGlitching.value = true
setTimeout(() => { isGlitching.value = false }, 380)
scheduledTimeout(() => { isGlitching.value = false }, 380)
}
setTimeout(triggerRevealGlitch, 500)
setTimeout(triggerRevealGlitch, 1200)
setTimeout(triggerRevealGlitch, 2000)
// Keep glass-throw active long enough for sidebar (last to animate, ~7.5s)
setTimeout(() => {
showZoomIn.value = false
}, 8000)
// Trigger welcome typing on Home after main content animation finishes
setTimeout(() => {
scheduledTimeout(triggerRevealGlitch, 500)
scheduledTimeout(triggerRevealGlitch, 1200)
scheduledTimeout(triggerRevealGlitch, 2000)
scheduledTimeout(() => { showZoomIn.value = false }, 8000)
scheduledTimeout(() => {
loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false)
}, 4000)
}
updateTabBarHeight()
updateAppsTabIndicator()
updateNetworkTabIndicator()
window.addEventListener('resize', () => {
updateTabBarHeight()
updateAppsTabIndicator()
updateNetworkTabIndicator()
})
onResize()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
document.body.classList.remove('dashboard-active')
window.removeEventListener('resize', updateTabBarHeight)
window.removeEventListener('resize', onResize)
for (const id of pendingTimers) clearTimeout(id)
pendingTimers.length = 0
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
})
// Watch route changes to update indicator position
@@ -676,14 +583,6 @@ const mobileNavItems = [
},
]
// Use appropriate nav items based on screen size
// @ts-ignore - Computed kept for future use
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const navItems = computed(() => {
if (typeof window === 'undefined') return desktopNavItems
return window.innerWidth >= 768 ? desktopNavItems : mobileNavItems
})
function getIconPath(iconName: string): string[] {
const icons: Record<string, string[]> = {
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
@@ -846,9 +745,6 @@ function getTransitionName(currentRoute: any) {
.zoom-reveal-bg {
animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
transform-origin: center center;
}
.zoom-reveal-bg {
opacity: 0;
transform: scale(0.15);
filter: blur(24px);

View File

@@ -130,7 +130,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
@@ -154,21 +154,32 @@ const isSetupMode = computed(() => {
return import.meta.env.VITE_DEV_MODE === 'setup'
})
let unlockHandler: (() => void) | null = null
function removeUnlockListeners() {
if (unlockHandler) {
document.removeEventListener('click', unlockHandler)
document.removeEventListener('touchstart', unlockHandler)
document.removeEventListener('keydown', unlockHandler)
unlockHandler = null
}
}
onBeforeUnmount(removeUnlockListeners)
onMounted(async () => {
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
const unlock = () => {
unlockHandler = () => {
if (!fromSplash) {
resumeAudioContext()
startSynthwave()
}
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
removeUnlockListeners()
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
document.addEventListener('click', unlockHandler, { once: true })
document.addEventListener('touchstart', unlockHandler, { once: true })
document.addEventListener('keydown', unlockHandler, { once: true })
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })

View File

@@ -366,7 +366,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
@@ -893,197 +893,110 @@ function viewAppDetails(app: any) {
}
}
const activeTimers: ReturnType<typeof setTimeout>[] = []
const activeIntervals: ReturnType<typeof setInterval>[] = []
function trackTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => {
const idx = activeTimers.indexOf(id)
if (idx !== -1) activeTimers.splice(idx, 1)
fn()
}, ms)
activeTimers.push(id)
return id
}
function trackInterval(fn: () => void, ms: number) {
const id = setInterval(fn, ms)
activeIntervals.push(id)
return id
}
function clearTrackedInterval(id: ReturnType<typeof setInterval>) {
clearInterval(id)
const idx = activeIntervals.indexOf(id)
if (idx !== -1) activeIntervals.splice(idx, 1)
}
onBeforeUnmount(() => {
for (const t of activeTimers) clearTimeout(t)
activeTimers.length = 0
for (const i of activeIntervals) clearInterval(i)
activeIntervals.length = 0
})
function startInstallPolling(appId: string, statusMessage: string) {
const interval = trackInterval(() => {
const current = installingApps.value.get(appId)
if (!current) { clearTrackedInterval(interval); return }
const newAttempt = current.attempt + 1
installingApps.value.set(appId, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
message: statusMessage
})
if (isInstalled(appId)) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
trackTimeout(() => { installingApps.value.delete(appId) }, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearTrackedInterval(interval)
installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' })
trackTimeout(() => { installingApps.value.delete(appId) }, 5000)
}
}, 1000)
}
async function installApp(app: any) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return
// Add to installing map
installingApps.value.set(app.id, {
id: app.id,
title: app.title,
status: 'downloading',
progress: 10,
message: 'Preparing installation...',
attempt: 0
id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
console.log('[Marketplace] Installing local app:', { id: app.id, url: installUrl, version: app.version })
// Update progress
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'downloading',
progress: 30,
message: 'Downloading package...'
})
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' })
await rpcClient.call({
method: 'package.install',
params: {
id: app.id,
url: installUrl,
version: app.version
}
})
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
// Update progress
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'installing',
progress: 60,
message: 'Installing package...'
})
// Wait for installation to complete (poll for package to appear)
const checkInstalled = setInterval(() => {
const current = installingApps.value.get(app.id)
if (!current) {
clearInterval(checkInstalled)
return
}
const newAttempt = current.attempt + 1
installingApps.value.set(app.id, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
message: 'Starting application...'
})
if (isInstalled(app.id)) {
clearInterval(checkInstalled)
installingApps.value.set(app.id, {
...current,
status: 'complete',
progress: 100,
message: 'Installation complete!'
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearInterval(checkInstalled)
installingApps.value.set(app.id, {
...current,
status: 'error',
progress: 0,
message: 'Installation timeout'
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
}
}, 1000)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' })
startInstallPolling(app.id, 'Starting application...')
} catch (err) {
console.error('Installation failed:', err)
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'error',
progress: 0,
message: `Failed: ${err}`
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
}
}
async function installCommunityApp(app: any) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
// Add to installing map
installingApps.value.set(app.id, {
id: app.id,
title: app.title,
status: 'downloading',
progress: 10,
message: 'Pulling Docker image...',
attempt: 0
id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
})
try {
console.log(`[Marketplace] Installing Docker app ${app.title} using image ${app.dockerImage}`)
// Update progress
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'downloading',
progress: 20,
message: 'Downloading container image...'
})
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
await rpcClient.call({
method: 'package.install',
params: {
id: app.id,
dockerImage: app.dockerImage,
version: app.version
},
timeout: 180000 // 3 minutes for large images like Nextcloud
params: { id: app.id, dockerImage: app.dockerImage, version: app.version },
timeout: 180000
})
// Update progress
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'installing',
progress: 60,
message: 'Starting container...'
})
// Wait for installation to complete (poll for package to appear)
const checkInstalled = setInterval(() => {
const current = installingApps.value.get(app.id)
if (!current) {
clearInterval(checkInstalled)
return
}
const newAttempt = current.attempt + 1
installingApps.value.set(app.id, {
...current,
attempt: newAttempt,
progress: Math.min(60 + (newAttempt * 0.5), 95),
message: 'Initializing application...'
})
if (isInstalled(app.id)) {
clearInterval(checkInstalled)
installingApps.value.set(app.id, {
...current,
status: 'complete',
progress: 100,
message: 'Installation complete!'
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 2000)
} else if (newAttempt >= maxAttempts.value) {
clearInterval(checkInstalled)
installingApps.value.set(app.id, {
...current,
status: 'error',
progress: 0,
message: 'Installation timeout'
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
}
}, 1000)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
startInstallPolling(app.id, 'Initializing application...')
} catch (err) {
console.error('[Marketplace] Installation failed:', err)
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'error',
progress: 0,
message: `Failed: ${err}`
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
}
}
@@ -1095,24 +1008,15 @@ async function sideloadPackage() {
sideloadSuccess.value = ''
try {
console.log(`Sideloading package from ${sideloadUrl.value}...`)
await rpcClient.call({
method: 'package.sideload',
params: {
url: sideloadUrl.value
}
})
await rpcClient.call({ method: 'package.sideload', params: { url: sideloadUrl.value } })
sideloadSuccess.value = 'Package installed successfully!'
sideloadUrl.value = ''
// Close modal and navigate to apps page after short delay
setTimeout(() => {
trackTimeout(() => {
showSideloadModal.value = false
router.push('/dashboard/apps')
router.push('/dashboard/apps').catch(() => {})
}, 1500)
} catch (err: any) {
console.error('Sideload failed:', err)
sideloadError.value = err.message || 'Failed to install package'

View File

@@ -326,7 +326,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
@@ -392,48 +392,50 @@ const features = computed(() => {
]
})
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
if (import.meta.env.DEV) console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
try {
// Get app data from composable
const loadedApp = getCurrentApp()
if (loadedApp && loadedApp.id === appId.value) {
app.value = loadedApp
console.log('[MarketplaceAppDetails] App loaded successfully:', app.value)
loading.value = false
} else {
console.warn('[MarketplaceAppDetails] App data not found in composable')
loading.value = false
// Navigate back to marketplace
setTimeout(() => {
router.push('/dashboard/marketplace')
pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
} catch (e) {
console.error('[MarketplaceAppDetails] Error loading app data:', e)
loading.value = false
setTimeout(() => {
router.push('/dashboard/marketplace')
pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
})
onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
})
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
target.src = '/assets/img/logo-archipelago.svg'
}
function goBack() {
router.push('/dashboard/marketplace')
router.push('/dashboard/marketplace').catch(() => {})
}
function goToInstalledApp() {
router.push({
path: `/dashboard/apps/${appId.value}`,
query: { from: 'marketplace' }
})
}).catch(() => {})
}
async function installApp() {
@@ -478,8 +480,7 @@ async function installApp() {
// Wait a moment for the package to be registered
await new Promise(resolve => setTimeout(resolve, 1000))
// Navigate to the installed app
router.push(`/dashboard/apps/${appId.value}`)
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
} catch (err: any) {
installError.value = err.message || 'Installation failed. Please try again.'
console.error('[MarketplaceAppDetails] Failed to install app:', err)

View File

@@ -284,24 +284,26 @@ async function handleChangePassword() {
}
}
let copiedTimer: ReturnType<typeof setTimeout> | null = null
async function copyOnionAddress() {
const addr = serverTorAddress.value
if (!addr) return
try {
await navigator.clipboard.writeText(addr)
copiedOnion.value = true
setTimeout(() => { copiedOnion.value = false }, 2000)
} catch {
// Fallback for older browsers
const ta = document.createElement('textarea')
ta.value = addr
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copiedOnion.value = true
setTimeout(() => { copiedOnion.value = false }, 2000)
}
copiedOnion.value = true
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => { copiedOnion.value = false }, 2000)
}
function closeChangePasswordModal() {
@@ -324,7 +326,7 @@ onMounted(async () => {
})
async function handleLogout() {
await store.logout()
router.push('/login')
try { await store.logout() } catch { /* proceed */ }
router.push('/login').catch(() => { window.location.href = '/login' })
}
</script>