frontend: polish app launch and release experience
This commit is contained in:
+170
-7
@@ -89,6 +89,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
|
||||
import { shouldShowIntroSplash } from '@/utils/introSplash'
|
||||
|
||||
const router = useRouter()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
@@ -176,6 +177,129 @@ const route = useRoute()
|
||||
// Start with splash hidden — onMounted decides whether to show it
|
||||
const showSplash = ref(false)
|
||||
const isReady = ref(false)
|
||||
let modalOverlayObserver: MutationObserver | null = null
|
||||
let lockedScrollY = 0
|
||||
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
|
||||
let bodyLockedForModal = false
|
||||
let modalTouchY: number | null = null
|
||||
|
||||
function hasBlockingOverlay() {
|
||||
if (typeof document === 'undefined') return false
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
|
||||
.some((el) => {
|
||||
const style = window.getComputedStyle(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.pointerEvents !== 'none'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0
|
||||
})
|
||||
}
|
||||
|
||||
function visibleBlockingOverlays() {
|
||||
if (typeof document === 'undefined') return []
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
|
||||
.filter((el) => {
|
||||
const style = window.getComputedStyle(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.pointerEvents !== 'none'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0
|
||||
})
|
||||
}
|
||||
|
||||
function closestOverlay(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
|
||||
}
|
||||
|
||||
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
let el: HTMLElement | null = target
|
||||
while (el && overlay.contains(el)) {
|
||||
const style = window.getComputedStyle(el)
|
||||
const canScrollY = /(auto|scroll)/.test(style.overflowY)
|
||||
&& el.scrollHeight > el.clientHeight
|
||||
if (canScrollY) {
|
||||
if (deltaY < 0 && el.scrollTop > 0) return true
|
||||
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
|
||||
}
|
||||
if (el === overlay) break
|
||||
el = el.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containModalWheel(ev: WheelEvent) {
|
||||
if (!bodyLockedForModal) return
|
||||
const overlay = closestOverlay(ev.target)
|
||||
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function containModalTouchStart(ev: TouchEvent) {
|
||||
modalTouchY = ev.touches[0]?.clientY ?? null
|
||||
}
|
||||
|
||||
function containModalTouchMove(ev: TouchEvent) {
|
||||
if (!bodyLockedForModal) return
|
||||
const currentY = ev.touches[0]?.clientY
|
||||
if (currentY === undefined || modalTouchY === null) {
|
||||
ev.preventDefault()
|
||||
return
|
||||
}
|
||||
const deltaY = modalTouchY - currentY
|
||||
modalTouchY = currentY
|
||||
const overlay = closestOverlay(ev.target)
|
||||
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function lockBodyForModal() {
|
||||
if (bodyLockedForModal || typeof document === 'undefined') return
|
||||
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
|
||||
previousBodyStyles = {
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
left: document.body.style.left,
|
||||
right: document.body.style.right,
|
||||
width: document.body.style.width,
|
||||
overflow: document.body.style.overflow,
|
||||
}
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.top = `-${lockedScrollY}px`
|
||||
document.body.style.left = '0'
|
||||
document.body.style.right = '0'
|
||||
document.body.style.width = '100%'
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.documentElement.classList.add('modal-scroll-locked')
|
||||
bodyLockedForModal = true
|
||||
}
|
||||
|
||||
function unlockBodyForModal() {
|
||||
if (!bodyLockedForModal || typeof document === 'undefined') return
|
||||
document.body.style.position = previousBodyStyles.position || ''
|
||||
document.body.style.top = previousBodyStyles.top || ''
|
||||
document.body.style.left = previousBodyStyles.left || ''
|
||||
document.body.style.right = previousBodyStyles.right || ''
|
||||
document.body.style.width = previousBodyStyles.width || ''
|
||||
document.body.style.overflow = previousBodyStyles.overflow || ''
|
||||
document.documentElement.classList.remove('modal-scroll-locked')
|
||||
window.scrollTo(0, lockedScrollY)
|
||||
previousBodyStyles = {}
|
||||
bodyLockedForModal = false
|
||||
modalTouchY = null
|
||||
}
|
||||
|
||||
function syncModalBodyLock() {
|
||||
if (hasBlockingOverlay()) lockBodyForModal()
|
||||
else unlockBodyForModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if splash screen should be shown
|
||||
@@ -213,18 +337,51 @@ onMounted(async () => {
|
||||
window.addEventListener('keydown', onUserActivity)
|
||||
window.addEventListener('touchstart', onUserActivity)
|
||||
window.addEventListener('message', onShareToMeshMessage)
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
|
||||
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
|
||||
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
|
||||
modalOverlayObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(syncModalBodyLock)
|
||||
})
|
||||
modalOverlayObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
})
|
||||
syncModalBodyLock()
|
||||
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
||||
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot)
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
const splashCandidate = !seenIntro
|
||||
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
|
||||
if (fromBoot && !seenIntro) {
|
||||
if (splashCandidate && onboardingComplete !== true) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
onboardingComplete = await checkOnboardingStatus()
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
}
|
||||
|
||||
if (!seenIntro && onboardingComplete === true) {
|
||||
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
|
||||
seenIntro = true
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
|
||||
|
||||
if (shouldShowIntroSplash({
|
||||
seenIntro,
|
||||
routePath: route.path,
|
||||
fromBoot,
|
||||
devMode: import.meta.env.VITE_DEV_MODE,
|
||||
onboardingComplete,
|
||||
})) {
|
||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||
showSplash.value = true
|
||||
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
|
||||
// Normal first visit (not boot mode) — show splash intro
|
||||
showSplash.value = true
|
||||
} else {
|
||||
// Already seen intro, direct route, or boot mode (boot screen handles intro)
|
||||
// Set isReady BEFORE hiding splash to prevent flash of partial content
|
||||
@@ -243,6 +400,12 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onUserActivity)
|
||||
window.removeEventListener('touchstart', onUserActivity)
|
||||
window.removeEventListener('message', onShareToMeshMessage)
|
||||
document.removeEventListener('wheel', containModalWheel, { capture: true })
|
||||
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
|
||||
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
|
||||
modalOverlayObserver?.disconnect()
|
||||
modalOverlayObserver = null
|
||||
unlockBodyForModal()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user