362 lines
13 KiB
Vue
362 lines
13 KiB
Vue
<template>
|
|
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
|
<!-- Skip to main content link for keyboard users -->
|
|
<!-- Skip-to-content handled by controller nav sidebar→main transition -->
|
|
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
|
<div class="bg-perspective-container">
|
|
<!-- 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 },
|
|
{ 'zoom-reveal-bg': showZoomIn }
|
|
]"
|
|
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
|
/>
|
|
<!-- Background - detail layer (only visible during app/marketplace detail 3D transition) -->
|
|
<div
|
|
ref="bgAlt"
|
|
class="bg-layer bg-fullwidth"
|
|
: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/${backgroundImage})` }"
|
|
/>
|
|
<div
|
|
class="bg-glitch-layer-2"
|
|
:class="{ 'glitch-active': isGlitching }"
|
|
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
|
/>
|
|
<div
|
|
class="bg-glitch-scan"
|
|
:class="{ 'glitch-active': isGlitching }"
|
|
/>
|
|
<!-- Glitch overlays removed — only intro glitch plays (via isGlitching) -->
|
|
</div>
|
|
|
|
<!-- Oomph accent - brief impact flash when dashboard loads -->
|
|
<div
|
|
v-if="showZoomIn"
|
|
class="fixed inset-0 pointer-events-none z-[100] oomph-flash"
|
|
aria-hidden="true"
|
|
/>
|
|
<!-- Reveal flashes and glitch overlay - enthralling entrance -->
|
|
<div
|
|
v-if="showZoomIn"
|
|
class="fixed inset-0 pointer-events-none z-[99] reveal-flash-glitch"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<!-- Background overlay - uniform 0.2 opacity -->
|
|
<div
|
|
class="fixed inset-0 pointer-events-none bg-black/20"
|
|
style="z-index: -5;"
|
|
/>
|
|
|
|
<!-- Sidebar - Desktop Only -->
|
|
<DashboardSidebar :show-zoom-in="showZoomIn" @logout="handleLogout" />
|
|
|
|
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
|
<main
|
|
id="main-content"
|
|
data-controller-zone="main"
|
|
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
|
:class="{ 'glass-throw-main': showZoomIn }"
|
|
tabindex="-1"
|
|
@pointerenter="activateMainScroll"
|
|
@wheel.capture="activateMainScroll"
|
|
>
|
|
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
|
<!-- Controller zone entry point - no switcher -->
|
|
</div>
|
|
|
|
<!-- Connection Status Banners -->
|
|
<ConnectionBanner />
|
|
|
|
<div class="perspective-container-wrapper glass-piece" :class="{ 'glass-throw-content': showZoomIn && !isHomeRoute }">
|
|
<div class="perspective-container">
|
|
<RouterView v-slot="{ Component, route }">
|
|
<Transition :name="getTransitionName(route)">
|
|
<div :key="route.path" class="view-wrapper">
|
|
<div
|
|
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
|
:class="[
|
|
'h-full dashboard-scroll-panel mobile-scroll-pad',
|
|
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
|
|
mobileTabPaddingTop ? 'overflow-y-auto' : ''
|
|
]"
|
|
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
|
class="mobile-safe-top"
|
|
>
|
|
<component :is="Component" />
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="[
|
|
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
|
|
needsMobileBackButtonSpace
|
|
? 'mobile-scroll-pad-back'
|
|
: 'mobile-scroll-pad'
|
|
]"
|
|
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
|
|
>
|
|
<component :is="Component" class="view-container flex-none" />
|
|
<!-- Bottom spacer — scroll clearance on all pages -->
|
|
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</RouterView>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel mode app session — renders alongside current page content -->
|
|
<Transition name="panel-slide">
|
|
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
|
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
|
|
</div>
|
|
</Transition>
|
|
</main>
|
|
|
|
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
|
<DashboardMobileNav ref="mobileNavRef" :show-zoom-in="showZoomIn" />
|
|
|
|
<!-- Health Notifications Toast -->
|
|
<HealthNotifications />
|
|
|
|
<!-- First-use companion intro overlay -->
|
|
<CompanionIntroOverlay />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
|
import { useAppStore } from '../stores/app'
|
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
|
import AppSession from '@/views/AppSession.vue'
|
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
|
|
|
import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
|
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
|
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
|
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
|
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
|
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
|
import '@/views/dashboard/dashboard-styles.css'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const store = useAppStore()
|
|
const appLauncher = useAppLauncherStore()
|
|
const loginTransition = useLoginTransitionStore()
|
|
const { getTransitionName } = useRouteTransitions()
|
|
|
|
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
|
|
}
|
|
|
|
const showAltBackground = ref(false)
|
|
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
|
const isGlitching = ref(false)
|
|
|
|
const backgroundImage = computed(() => {
|
|
const mapped = ROUTE_BACKGROUNDS[route.path]
|
|
if (mapped) return mapped
|
|
// Cloud subpages (folders) use the same background as Cloud
|
|
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.jpg'
|
|
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
|
return 'bg-home.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/discover') ||
|
|
p.includes('/dashboard/cloud')
|
|
})
|
|
|
|
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 WEB5_DETAIL_ROUTES = ['/dashboard/server/federation', '/dashboard/monitoring', '/dashboard/fleet']
|
|
const needsMobileBackButtonSpace = computed(() =>
|
|
isDetailRoute(route.path) || WEB5_DETAIL_ROUTES.includes(route.path)
|
|
)
|
|
|
|
const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null)
|
|
|
|
const mobileTabPaddingTop = computed(() => {
|
|
return mobileNavRef.value?.mobileTabPaddingTop ?? 0
|
|
})
|
|
|
|
// Scroll position save/restore — only restore when coming BACK from a detail page
|
|
const savedScrollPositions = new Map<string, number>()
|
|
let previousRoutePath = ''
|
|
|
|
function isAnyDetailRoute(path: string): boolean {
|
|
return isDetailRoute(path) || WEB5_DETAIL_ROUTES.includes(path)
|
|
}
|
|
|
|
function saveCurrentScroll() {
|
|
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
|
|
if (el && previousRoutePath) {
|
|
savedScrollPositions.set(previousRoutePath, el.scrollTop)
|
|
}
|
|
}
|
|
|
|
function restoreScroll(path: string) {
|
|
const saved = savedScrollPositions.get(path)
|
|
if (saved == null) return
|
|
nextTick(() => {
|
|
// Wait for transition to settle
|
|
setTimeout(() => {
|
|
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
|
|
if (el) el.scrollTop = saved
|
|
}, 50)
|
|
})
|
|
}
|
|
|
|
function activateMainScroll() {
|
|
const active = document.activeElement as HTMLElement | null
|
|
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
|
|
active.blur()
|
|
document.getElementById('main-content')?.focus({ preventScroll: true })
|
|
}
|
|
}
|
|
|
|
watch(() => route.path, (newPath) => {
|
|
const isAppDetails = isDetailRoute(newPath)
|
|
const wasAppDetails = showAltBackground.value
|
|
const oldPath = previousRoutePath
|
|
const wasDetail = isAnyDetailRoute(oldPath)
|
|
const isDetail = isAnyDetailRoute(newPath)
|
|
|
|
// Save scroll position of the page we're leaving
|
|
saveCurrentScroll()
|
|
|
|
// Restore scroll only when returning from a detail page to the parent list
|
|
if (wasDetail && !isDetail) {
|
|
restoreScroll(newPath)
|
|
}
|
|
|
|
previousRoutePath = newPath
|
|
|
|
showAltBackground.value = isAppDetails
|
|
|
|
if (isAppDetails && !wasAppDetails) {
|
|
scheduledTimeout(() => {
|
|
isGlitching.value = true
|
|
scheduledTimeout(() => { isGlitching.value = false }, 375)
|
|
}, 500)
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
previousRoutePath = route.path
|
|
document.body.classList.add('dashboard-active')
|
|
if (loginTransition.justCompletedOnboarding) {
|
|
// Full glitchy reveal — only on the very first dashboard entry
|
|
// right after onboarding (one-time event, persists in feel).
|
|
playDashboardLoadOomph()
|
|
showZoomIn.value = true
|
|
loginTransition.setPendingWelcomeTyping(true)
|
|
loginTransition.setJustCompletedOnboarding(false)
|
|
loginTransition.setJustLoggedIn(false)
|
|
const triggerRevealGlitch = () => {
|
|
isGlitching.value = true
|
|
scheduledTimeout(() => { isGlitching.value = false }, 380)
|
|
}
|
|
scheduledTimeout(triggerRevealGlitch, 500)
|
|
scheduledTimeout(triggerRevealGlitch, 1200)
|
|
scheduledTimeout(triggerRevealGlitch, 2000)
|
|
scheduledTimeout(() => { showZoomIn.value = false }, 8000)
|
|
scheduledTimeout(() => {
|
|
loginTransition.setStartWelcomeTyping(true)
|
|
loginTransition.setPendingWelcomeTyping(false)
|
|
}, 4000)
|
|
} else if (loginTransition.justLoggedIn) {
|
|
// Regular re-login — no zoom, no glitch. Just land on the
|
|
// dashboard and kick off the welcome typing quickly.
|
|
playDashboardLoadOomph()
|
|
loginTransition.setPendingWelcomeTyping(true)
|
|
loginTransition.setJustLoggedIn(false)
|
|
scheduledTimeout(() => {
|
|
loginTransition.setStartWelcomeTyping(true)
|
|
loginTransition.setPendingWelcomeTyping(false)
|
|
}, 300)
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKioskShortcuts)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.body.classList.remove('dashboard-active')
|
|
window.removeEventListener('keydown', handleKioskShortcuts)
|
|
for (const id of pendingTimers) clearTimeout(id)
|
|
pendingTimers.length = 0
|
|
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
|
})
|
|
|
|
function isKioskMode(): boolean {
|
|
try {
|
|
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
|
|
} catch { return false }
|
|
}
|
|
|
|
function handleKioskShortcuts(e: KeyboardEvent) {
|
|
if (!isKioskMode()) return
|
|
if (e.ctrlKey && e.shiftKey) {
|
|
if (e.key === 'R' || e.key === 'r') {
|
|
e.preventDefault()
|
|
router.push('/recovery')
|
|
} else if (e.key === 'H' || e.key === 'h') {
|
|
e.preventDefault()
|
|
router.push('/dashboard')
|
|
} else if (e.key === 'Q' || e.key === 'q') {
|
|
e.preventDefault()
|
|
if (confirm('Reboot the server?')) {
|
|
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await store.logout()
|
|
} catch {
|
|
/* proceed to login regardless */
|
|
}
|
|
router.push('/login').catch(() => {
|
|
window.location.href = '/login'
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<!-- Styles extracted to dashboard/dashboard-styles.css -->
|