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

View File

@ -171,7 +171,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher' import { useAppLauncherStore } from '../stores/appLauncher'
@ -187,7 +187,7 @@ const loadingActions = ref<Record<string, boolean>>({})
// Use real packages from store - no more dummy apps // Use real packages from store - no more dummy apps
const packages = computed(() => { const packages = computed(() => {
const realPackages = store.packages 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 || {} return realPackages || {}
}) })
@ -276,7 +276,7 @@ function launchApp(id: string) {
} }
// For other apps, navigate to app details which has launch functionality // 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 { function getStatusClass(state: PackageState): string {
@ -297,19 +297,20 @@ function getStatusClass(state: PackageState): string {
} }
function goToApp(id: 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) { async function startApp(id: string) {
loadingActions.value[id] = true loadingActions.value[id] = true
try { try {
await store.startPackage(id) await store.startPackage(id)
// Wait for state update from WebSocket if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
// The loader will be cleared when we receive the updated state actionTimers.set(id, setTimeout(() => {
// For now, keep a max timeout as fallback
setTimeout(() => {
loadingActions.value[id] = false loadingActions.value[id] = false
}, 5000) actionTimers.delete(id)
}, 5000))
} catch (err) { } catch (err) {
console.error('Failed to start app:', err) console.error('Failed to start app:', err)
loadingActions.value[id] = false loadingActions.value[id] = false
@ -320,18 +321,22 @@ async function stopApp(id: string) {
loadingActions.value[id] = true loadingActions.value[id] = true
try { try {
await store.stopPackage(id) await store.stopPackage(id)
// Wait for state update from WebSocket if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
// The loader will be cleared when we receive the updated state actionTimers.set(id, setTimeout(() => {
// For now, keep a max timeout as fallback
setTimeout(() => {
loadingActions.value[id] = false loadingActions.value[id] = false
}, 5000) actionTimers.delete(id)
}, 5000))
} catch (err) { } catch (err) {
console.error('Failed to stop app:', err) console.error('Failed to stop app:', err)
loadingActions.value[id] = false loadingActions.value[id] = false
} }
} }
onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear()
})
// @ts-ignore - Function kept for future use // @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
// 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 }"> <div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Background container with 3D perspective - full width to avoid letterboxing --> <!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container"> <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 <div
ref="bgDefault" ref="bgDefault"
class="bg-layer bg-fullwidth" class="bg-layer bg-fullwidth"
:class="[ :class="[
{ 'bg-transitioning-out': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground }, { 'bg-transitioning-out': showAltBackground },
{ 'zoom-reveal-bg': showZoomIn } { '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 <div
ref="bgAlt" ref="bgAlt"
class="bg-layer bg-fullwidth" class="bg-layer bg-fullwidth"
:class="{ 'bg-transitioning-in': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground }" :class="{ 'bg-transitioning-in': showAltBackground }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }" style="background-image: url(/assets/img/bg-intro-3.jpg)"
/> />
<!-- Glitch overlays - trigger on background change --> <!-- Glitch overlays - trigger on background change -->
<div <div
class="bg-glitch-layer-1" class="bg-glitch-layer-1"
:class="{ 'glitch-active': isGlitching }" :class="{ 'glitch-active': isGlitching }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }" :style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/> />
<div <div
class="bg-glitch-layer-2" class="bg-glitch-layer-2"
:class="{ 'glitch-active': isGlitching }" :class="{ 'glitch-active': isGlitching }"
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }" :style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/> />
<div <div
class="bg-glitch-scan" class="bg-glitch-scan"
@ -37,11 +37,11 @@
<!-- Continuous glitch/flash overlays - same as login, every 5s --> <!-- Continuous glitch/flash overlays - same as login, every 5s -->
<div <div
class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth" class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }" :style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/> />
<div <div
class="dashboard-glitch-layer dashboard-glitch-2 bg-fullwidth" 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 class="dashboard-glitch-scan" />
</div> </div>
@ -319,52 +319,61 @@ const store = useAppStore()
const loginTransition = useLoginTransitionStore() const loginTransition = useLoginTransitionStore()
const showZoomIn = ref(false) 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 showAltBackground = ref(false)
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/') 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) const isGlitching = ref(false)
// Background images const ROUTE_BACKGROUNDS: Record<string, string> = {
const currentBackgroundImage = computed(() => { '/dashboard': 'bg-home.jpg',
if (showWeb5Background.value) return 'bg-web5.jpg' '/dashboard/': 'bg-home.jpg',
if (showNetworkBackground.value) return 'bg-network.jpg' '/dashboard/apps': 'bg-myapps.jpg',
if (showSettingsBackground.value) return 'bg-settings.jpg' '/dashboard/marketplace': 'bg-appstore.jpg',
if (showMyAppsBackground.value) return 'bg-myapps.jpg' '/dashboard/cloud': 'bg-cloud.jpg',
if (showAppStoreBackground.value) return 'bg-appstore.jpg' '/dashboard/server': 'bg-network.jpg',
if (showCloudBackground.value) return 'bg-cloud.jpg' '/dashboard/web5': 'bg-web5.jpg',
if (showHomeBackground.value) return 'bg-home.jpg' '/dashboard/settings': 'bg-settings.jpg',
return 'bg-intro.jpg' }
const backgroundImage = computed(() => {
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return ROUTE_BACKGROUNDS[route.path] || 'bg-home.jpg'
}) })
const altBackgroundImage = computed(() => { const isDarkRoute = computed(() => {
if (showWeb5Background.value) return 'bg-web5.jpg' const p = route.path
if (showNetworkBackground.value) return 'bg-network.jpg' return p.includes('/dashboard/web5') ||
if (showSettingsBackground.value) return 'bg-settings.jpg' p.includes('/dashboard/server') ||
if (showMyAppsBackground.value) return 'bg-myapps.jpg' p.includes('/dashboard/settings') ||
if (showAppStoreBackground.value) return 'bg-appstore.jpg' (p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
if (showCloudBackground.value) return 'bg-cloud.jpg' p.includes('/dashboard/marketplace') ||
if (showHomeBackground.value) return 'bg-home.jpg' p.includes('/dashboard/cloud')
return 'bg-intro-3.jpg'
}) })
// Check if overlay should be dark (0.8 opacity) const showDarkOverlay = ref(isDarkRoute.value)
const showDarkOverlay = computed(() => { let overlayTimer: ReturnType<typeof setTimeout> | null = null
return showWeb5Overlay.value || showNetworkOverlay.value || showSettingsOverlay.value || showMyAppsOverlay.value || showAppStoreOverlay.value || showCloudOverlay.value
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 mobileTabBar = ref<HTMLElement | null>(null)
const appsTabRef = ref<HTMLElement | null>(null) const appsTabRef = ref<HTMLElement | null>(null)
const marketplaceTabRef = 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 networkTabIndicatorLeft = ref(0)
const networkTabIndicatorWidth = 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) => { watch(() => route.path, (newPath) => {
// Check if we're on app details OR marketplace app details
const isAppDetails = isDetailRoute(newPath) const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value 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 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) { if (isAppDetails && !wasAppDetails) {
setTimeout(() => { scheduledTimeout(() => {
isGlitching.value = true isGlitching.value = true
setTimeout(() => { scheduledTimeout(() => { isGlitching.value = false }, 375)
isGlitching.value = false }, 500)
}, 375) // Glitch duration - 25% faster
}, 500) // Wait for background 3D transition to complete
} }
}) })
@ -550,6 +460,12 @@ function updateNetworkTabIndicator() {
networkTabIndicatorWidth.value = tabRect.width networkTabIndicatorWidth.value = tabRect.width
} }
function onResize() {
updateTabBarHeight()
updateAppsTabIndicator()
updateNetworkTabIndicator()
}
onMounted(() => { onMounted(() => {
document.body.classList.add('dashboard-active') document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) { if (loginTransition.justLoggedIn) {
@ -557,39 +473,30 @@ onMounted(() => {
showZoomIn.value = true showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true) loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustLoggedIn(false) loginTransition.setJustLoggedIn(false)
// Trigger glitches during background reveal (0.5s, 1.2s, 2s into 2.8s zoom)
const triggerRevealGlitch = () => { const triggerRevealGlitch = () => {
isGlitching.value = true isGlitching.value = true
setTimeout(() => { isGlitching.value = false }, 380) scheduledTimeout(() => { isGlitching.value = false }, 380)
} }
setTimeout(triggerRevealGlitch, 500) scheduledTimeout(triggerRevealGlitch, 500)
setTimeout(triggerRevealGlitch, 1200) scheduledTimeout(triggerRevealGlitch, 1200)
setTimeout(triggerRevealGlitch, 2000) scheduledTimeout(triggerRevealGlitch, 2000)
// Keep glass-throw active long enough for sidebar (last to animate, ~7.5s) scheduledTimeout(() => { showZoomIn.value = false }, 8000)
setTimeout(() => { scheduledTimeout(() => {
showZoomIn.value = false
}, 8000)
// Trigger welcome typing on Home after main content animation finishes
setTimeout(() => {
loginTransition.setStartWelcomeTyping(true) loginTransition.setStartWelcomeTyping(true)
loginTransition.setPendingWelcomeTyping(false) loginTransition.setPendingWelcomeTyping(false)
}, 4000) }, 4000)
} }
updateTabBarHeight() onResize()
updateAppsTabIndicator() window.addEventListener('resize', onResize)
updateNetworkTabIndicator()
window.addEventListener('resize', () => {
updateTabBarHeight()
updateAppsTabIndicator()
updateNetworkTabIndicator()
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.body.classList.remove('dashboard-active') 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 // 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[] { function getIconPath(iconName: string): string[] {
const icons: Record<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'], 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 { .zoom-reveal-bg {
animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
transform-origin: center center; transform-origin: center center;
}
.zoom-reveal-bg {
opacity: 0; opacity: 0;
transform: scale(0.15); transform: scale(0.15);
filter: blur(24px); filter: blur(24px);

View File

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

View File

@ -366,7 +366,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client' 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) { async function installApp(app: any) {
if (installingApps.value.has(app.id) || isInstalled(app.id)) return if (installingApps.value.has(app.id) || isInstalled(app.id)) return
// Add to installing map
installingApps.value.set(app.id, { installingApps.value.set(app.id, {
id: app.id, id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0
title: app.title,
status: 'downloading',
progress: 10,
message: 'Preparing installation...',
attempt: 0
}) })
try { try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl 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({ await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } })
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...' })
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)
startInstallPolling(app.id, 'Starting application...')
} catch (err) { } catch (err) {
console.error('Installation failed:', err) console.error('Installation failed:', err)
installingApps.value.set(app.id, { installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
...installingApps.value.get(app.id)!, trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
status: 'error',
progress: 0,
message: `Failed: ${err}`
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
} }
} }
async function installCommunityApp(app: any) { async function installCommunityApp(app: any) {
if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
// Add to installing map
installingApps.value.set(app.id, { installingApps.value.set(app.id, {
id: app.id, id: app.id, title: app.title, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0
title: app.title,
status: 'downloading',
progress: 10,
message: 'Pulling Docker image...',
attempt: 0
}) })
try { try {
console.log(`[Marketplace] Installing Docker app ${app.title} using image ${app.dockerImage}`) installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' })
// Update progress
installingApps.value.set(app.id, {
...installingApps.value.get(app.id)!,
status: 'downloading',
progress: 20,
message: 'Downloading container image...'
})
await rpcClient.call({ await rpcClient.call({
method: 'package.install', method: 'package.install',
params: { params: { id: app.id, dockerImage: app.dockerImage, version: app.version },
id: app.id, timeout: 180000
dockerImage: app.dockerImage,
version: app.version
},
timeout: 180000 // 3 minutes for large images like Nextcloud
}) })
// Update progress installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' })
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)
startInstallPolling(app.id, 'Initializing application...')
} catch (err) { } catch (err) {
console.error('[Marketplace] Installation failed:', err) console.error('[Marketplace] Installation failed:', err)
installingApps.value.set(app.id, { installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` })
...installingApps.value.get(app.id)!, trackTimeout(() => { installingApps.value.delete(app.id) }, 5000)
status: 'error',
progress: 0,
message: `Failed: ${err}`
})
setTimeout(() => {
installingApps.value.delete(app.id)
}, 5000)
} }
} }
@ -1095,24 +1008,15 @@ async function sideloadPackage() {
sideloadSuccess.value = '' sideloadSuccess.value = ''
try { 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!' sideloadSuccess.value = 'Package installed successfully!'
sideloadUrl.value = '' sideloadUrl.value = ''
// Close modal and navigate to apps page after short delay trackTimeout(() => {
setTimeout(() => {
showSideloadModal.value = false showSideloadModal.value = false
router.push('/dashboard/apps') router.push('/dashboard/apps').catch(() => {})
}, 1500) }, 1500)
} catch (err: any) { } catch (err: any) {
console.error('Sideload failed:', err) console.error('Sideload failed:', err)
sideloadError.value = err.message || 'Failed to install package' sideloadError.value = err.message || 'Failed to install package'

View File

@ -326,7 +326,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client' import { rpcClient } from '../api/rpc-client'
@ -392,48 +392,50 @@ const features = computed(() => {
] ]
}) })
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => { onMounted(() => {
console.log('[MarketplaceAppDetails] Loading app ID:', appId.value) if (import.meta.env.DEV) console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
try { try {
// Get app data from composable
const loadedApp = getCurrentApp() const loadedApp = getCurrentApp()
if (loadedApp && loadedApp.id === appId.value) { if (loadedApp && loadedApp.id === appId.value) {
app.value = loadedApp app.value = loadedApp
console.log('[MarketplaceAppDetails] App loaded successfully:', app.value)
loading.value = false loading.value = false
} else { } else {
console.warn('[MarketplaceAppDetails] App data not found in composable')
loading.value = false loading.value = false
// Navigate back to marketplace pendingRedirect = setTimeout(() => {
setTimeout(() => { router.push('/dashboard/marketplace').catch(() => {})
router.push('/dashboard/marketplace')
}, 500) }, 500)
} }
} catch (e) { } catch (e) {
console.error('[MarketplaceAppDetails] Error loading app data:', e) console.error('[MarketplaceAppDetails] Error loading app data:', e)
loading.value = false loading.value = false
setTimeout(() => { pendingRedirect = setTimeout(() => {
router.push('/dashboard/marketplace') router.push('/dashboard/marketplace').catch(() => {})
}, 500) }, 500)
} }
}) })
onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
})
function handleImageError(e: Event) { function handleImageError(e: Event) {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement
target.src = '/assets/img/logo-archipelago.svg' target.src = '/assets/img/logo-archipelago.svg'
} }
function goBack() { function goBack() {
router.push('/dashboard/marketplace') router.push('/dashboard/marketplace').catch(() => {})
} }
function goToInstalledApp() { function goToInstalledApp() {
router.push({ router.push({
path: `/dashboard/apps/${appId.value}`, path: `/dashboard/apps/${appId.value}`,
query: { from: 'marketplace' } query: { from: 'marketplace' }
}) }).catch(() => {})
} }
async function installApp() { async function installApp() {
@ -478,8 +480,7 @@ async function installApp() {
// Wait a moment for the package to be registered // Wait a moment for the package to be registered
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise(resolve => setTimeout(resolve, 1000))
// Navigate to the installed app router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
router.push(`/dashboard/apps/${appId.value}`)
} catch (err: any) { } catch (err: any) {
installError.value = err.message || 'Installation failed. Please try again.' installError.value = err.message || 'Installation failed. Please try again.'
console.error('[MarketplaceAppDetails] Failed to install app:', err) 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() { async function copyOnionAddress() {
const addr = serverTorAddress.value const addr = serverTorAddress.value
if (!addr) return if (!addr) return
try { try {
await navigator.clipboard.writeText(addr) await navigator.clipboard.writeText(addr)
copiedOnion.value = true
setTimeout(() => { copiedOnion.value = false }, 2000)
} catch { } catch {
// Fallback for older browsers
const ta = document.createElement('textarea') const ta = document.createElement('textarea')
ta.value = addr ta.value = addr
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta) document.body.appendChild(ta)
ta.select() ta.select()
document.execCommand('copy') document.execCommand('copy')
document.body.removeChild(ta) 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() { function closeChangePasswordModal() {
@ -324,7 +326,7 @@ onMounted(async () => {
}) })
async function handleLogout() { async function handleLogout() {
await store.logout() try { await store.logout() } catch { /* proceed */ }
router.push('/login') router.push('/login').catch(() => { window.location.href = '/login' })
} }
</script> </script>