Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- Splash Screen (only on first visit) -->
|
||||
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||
|
||||
<!-- Main App Content - only show after splash and routing is complete -->
|
||||
<div v-if="!showSplash && !isReady" class="min-h-screen bg-black" />
|
||||
<RouterView v-else-if="!showSplash && isReady" />
|
||||
|
||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||
<SpotlightSearch />
|
||||
|
||||
<!-- CLI popup (F key) -->
|
||||
<CLIPopup />
|
||||
|
||||
<!-- App launcher overlay (iframe popup) -->
|
||||
<AppLauncherOverlay />
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastStack />
|
||||
|
||||
<!-- Screensaver -->
|
||||
<Screensaver />
|
||||
|
||||
<!-- Help guide modal (from spotlight) -->
|
||||
<HelpGuideModal
|
||||
:show="spotlightStore.helpModal.show"
|
||||
:title="spotlightStore.helpModal.title"
|
||||
:content="spotlightStore.helpModal.content"
|
||||
:related-path="spotlightStore.helpModal.relatedPath"
|
||||
@close="spotlightStore.closeHelpModal()"
|
||||
/>
|
||||
|
||||
<!-- PWA Update Prompt -->
|
||||
<PWAUpdatePrompt />
|
||||
|
||||
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
|
||||
<PWAInstallPrompt />
|
||||
|
||||
<!-- Global "mesh device detected" setup flow (fires on any page) -->
|
||||
<MeshDeviceSetupModal />
|
||||
<ExternalExplorerModal />
|
||||
|
||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||
<LndSeedBackupPrompt />
|
||||
|
||||
<!-- Global persistent audio player (bottom bar) -->
|
||||
<GlobalAudioPlayer />
|
||||
|
||||
<!-- Toast notifications - top right, glass style, any page -->
|
||||
<Teleport to="body">
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toastMessage.show"
|
||||
@click="messageToast.dismissToastAndOpenMessages"
|
||||
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">New message</p>
|
||||
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
|
||||
<p class="mt-1 text-xs text-orange-400">Click to view</p>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="messageToast.closeToast"
|
||||
aria-label="Dismiss notification"
|
||||
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
|
||||
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||
import CLIPopup from './components/CLIPopup.vue'
|
||||
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
|
||||
import ToastStack from './components/ToastStack.vue'
|
||||
import Screensaver from './components/Screensaver.vue'
|
||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||
import { useMeshStore } from './stores/mesh'
|
||||
|
||||
import { useControllerNav } from '@/composables/useControllerNav'
|
||||
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
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()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
const cliStore = useCLIStore()
|
||||
const appStore = useAppStore()
|
||||
const uiModeStore = useUIModeStore()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
|
||||
useControllerNav()
|
||||
|
||||
function syncKioskSafeArea() {
|
||||
if (typeof document === 'undefined') return
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
// Very first kiosk boot: the launcher opens /kiosk before the route
|
||||
// guard has had a chance to persist localStorage.kiosk.
|
||||
|| window.location.pathname === '/kiosk'
|
||||
// Global styling hook: kiosk chromium runs software-composited
|
||||
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
|
||||
// (3D bg layers, animated mix-blend overlays) fail and paint black.
|
||||
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
|
||||
if (isKiosk) {
|
||||
// AIUI (same-origin iframe) themes from localStorage['aiui-theme'], and a
|
||||
// stored value beats its prefers-color-scheme fallback. Kiosk profiles
|
||||
// that ran before the launcher forced a dark color-scheme have "light"
|
||||
// baked in — white panels on an otherwise dark TV UI — so pin it dark.
|
||||
try { localStorage.setItem('aiui-theme', 'dark') } catch { /* storage full/denied */ }
|
||||
}
|
||||
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
|
||||
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
|
||||
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
|
||||
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
|
||||
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
|
||||
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
|
||||
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
|
||||
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
|
||||
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
|
||||
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
|
||||
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
|
||||
}
|
||||
}
|
||||
|
||||
// Start/stop message polling and remote relay when auth state changes
|
||||
watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
messageToast.startPolling()
|
||||
screensaverStore.resetInactivityTimer()
|
||||
// Kiosk included: the backend's remote-input path is relay-only (it
|
||||
// validates and broadcasts; it never runs xdotool), so the browser-side
|
||||
// relay is the ONLY consumer of companion input. Skipping it on kiosk —
|
||||
// an assumption from a long-gone system-level xdotool injector — left
|
||||
// TVs deaf to the companion remote.
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
stopRemoteRelay()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Reset screensaver inactivity on user activity (when authenticated)
|
||||
function onUserActivity() {
|
||||
if (appStore.isAuthenticated && !screensaverStore.isActive) {
|
||||
screensaverStore.resetInactivityTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||
// Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing)
|
||||
const target = e.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
if (mod && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
spotlightStore.toggle()
|
||||
return
|
||||
}
|
||||
// F key - CLI popup (skip when in input or modifier held)
|
||||
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
|
||||
e.preventDefault()
|
||||
cliStore.toggle()
|
||||
return
|
||||
}
|
||||
// Cmd+1/2/3 - switch UI mode (skip when in input)
|
||||
if (mod && !isInput && appStore.isAuthenticated) {
|
||||
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
|
||||
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
|
||||
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
|
||||
}
|
||||
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
|
||||
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
|
||||
e.preventDefault()
|
||||
uiModeStore.cycleMode()
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
// 's' key activates screensaver when authenticated (skip if typing in input)
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
|
||||
e.preventDefault()
|
||||
screensaverStore.activate()
|
||||
}
|
||||
}
|
||||
// Keyboard typing sound - plays on any character typed in inputs (global)
|
||||
if (isInput && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
playKeyboardTypingSound()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* Splash is skipped if:
|
||||
* - User has already seen the intro
|
||||
* - User is on a direct route (refresh/bookmark)
|
||||
*/
|
||||
// Fix Chromium backdrop-filter rendering bug: when tab loses/regains focus,
|
||||
// the compositor fails to repaint backdrop-filter layers over animated
|
||||
// fixed-position overlays (body::before/after with mix-blend-mode).
|
||||
// On return: strip backdrop-filter via class, wait a frame, then restore.
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
document.documentElement.classList.add('tab-hidden')
|
||||
} else {
|
||||
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
|
||||
document.documentElement.classList.add('no-backdrop')
|
||||
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
|
||||
// Use setTimeout — Chromium batches rAFs on tab return
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('no-backdrop')
|
||||
// Step 3: resume animations after backdrop-filter layers are established
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('tab-hidden')
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncKioskSafeArea()
|
||||
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
|
||||
// setup modal on any page (the Mesh view's own poll takes over there).
|
||||
useMeshStore().startGlobalDetection()
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('mousemove', onUserActivity)
|
||||
window.addEventListener('mousedown', onUserActivity)
|
||||
window.addEventListener('keydown', onUserActivity)
|
||||
window.addEventListener('touchstart', onUserActivity)
|
||||
window.addEventListener('message', onShareToMeshMessage)
|
||||
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')
|
||||
// One-shot "Replay intro" request from the login screen — must survive the
|
||||
// auto-re-mark below (which otherwise instantly suppresses the replay on any
|
||||
// onboarded node).
|
||||
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
||||
// The route object still holds the router's START_LOCATION ('/') here —
|
||||
// the initial navigation resolves asynchronously — so a deep-route load
|
||||
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
|
||||
// bar is the only truthful source this early.
|
||||
//
|
||||
// The kiosk chromium opens /kiosk — a marker route whose beforeEnter
|
||||
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
|
||||
// root boot, not a deep route: without normalizing it, the fresh-install
|
||||
// kiosk never plays the typing intro (deep-route suppression regression).
|
||||
const rawBootPath = window.location.pathname
|
||||
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
|
||||
// Public demo: every fresh boot at the root (first visit or a browser
|
||||
// refresh) starts with the typing splash for the full effect. In-session SPA
|
||||
// navigation never remounts App, and deep-route refreshes keep their place.
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
// Root boots always ask the backend — even when this browser thinks it has
|
||||
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
|
||||
// are per-origin browser state: after a reinstall (or another node coming
|
||||
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
|
||||
// a fresh install's intro / misroute it to login.
|
||||
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
|
||||
|
||||
if (splashCandidate) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
// Bound the pre-splash status check: its retry ladder can spend ~30s
|
||||
// against a still-booting backend, and this await holds the black
|
||||
// "!isReady" screen the whole time — exactly where the typing intro
|
||||
// should be playing on a fresh kiosk boot. Unknown within 2.5s → let
|
||||
// the splash play (a fresh install IS the slow-backend case; onboarded
|
||||
// nodes answer in milliseconds, so their suppression path is intact).
|
||||
// handleSplashComplete re-checks with full retries after the intro.
|
||||
const live = await Promise.race([
|
||||
checkOnboardingStatus(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
|
||||
])
|
||||
if (live !== null) onboardingComplete = live
|
||||
if (live === false && seenIntro) {
|
||||
// Backend-confirmed fresh node behind a browser with a stale flag —
|
||||
// drop it so this boot (and every later one) plays the intro.
|
||||
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
|
||||
seenIntro = false
|
||||
}
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
}
|
||||
|
||||
if (!replayRequested && !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: bootPath,
|
||||
fromBoot,
|
||||
devMode: import.meta.env.VITE_DEV_MODE,
|
||||
onboardingComplete,
|
||||
replayRequested,
|
||||
})) {
|
||||
// The intro is definitely playing — unmute the whole cinematic (speech,
|
||||
// synthwave, pops) even on browsers whose localStorage says onboarding is
|
||||
// complete; the sound gate otherwise silences replays.
|
||||
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
|
||||
enableCinematicSounds()
|
||||
// Kick off the intro video download NOW: the splash's <video> element only
|
||||
// mounts ~20s in (after the typing sequence), and on a cold cache the file
|
||||
// would otherwise start fetching mid-sequence and stutter. A detached
|
||||
// <video preload=auto> is used because Chromium does not support
|
||||
// <link rel=preload as=video> — the media cache then serves the splash's
|
||||
// element, which uses the identical URL.
|
||||
try {
|
||||
const warm = document.createElement('video')
|
||||
warm.muted = true
|
||||
warm.preload = 'auto'
|
||||
warm.src = '/assets/video/video-intro.mp4?v=8'
|
||||
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
|
||||
} catch { /* ignore */ }
|
||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||
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
|
||||
await router.isReady()
|
||||
isReady.value = true
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.documentElement.classList.remove('kiosk-safe-area')
|
||||
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
|
||||
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('mousemove', onUserActivity)
|
||||
window.removeEventListener('mousedown', onUserActivity)
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
* Phase 3c: marketplace app iframes share files into mesh chats by POSTing
|
||||
* to /api/share-to-mesh then postMessaging the CID back to this parent
|
||||
* window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the
|
||||
* stash on mount and stages it as a pending attachment.
|
||||
*/
|
||||
function onShareToMeshMessage(ev: MessageEvent) {
|
||||
// Same-origin senders only (matches Chat.vue's handler) — otherwise any
|
||||
// embedded frame can force-navigate the UI to /mesh with a staged CID.
|
||||
if (ev.origin !== window.location.origin) return
|
||||
const data = ev.data as { type?: string; cid?: string } | null
|
||||
if (!data || data.type !== 'share-to-mesh' || !data.cid) return
|
||||
try {
|
||||
sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data))
|
||||
} catch {
|
||||
/* quota — fall through */
|
||||
}
|
||||
if (route.path !== '/mesh') {
|
||||
router.push('/mesh')
|
||||
} else {
|
||||
// Already on /mesh — dispatch a synthetic event so the view picks it up.
|
||||
window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle splash screen completion
|
||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||
*/
|
||||
async function handleSplashComplete() {
|
||||
sessionStorage.setItem('archipelago_from_splash', '1')
|
||||
|
||||
// Commit the destination route BEFORE revealing RouterView. Revealing
|
||||
// first rendered whatever route was current — '/' → RootRedirect's
|
||||
// spinner — for a beat between the intro's final frame and the
|
||||
// onboarding/login screen actually mounting: a visible flash on every
|
||||
// fresh install. Await the push so the first RouterView render is
|
||||
// already the destination.
|
||||
const reveal = () => {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
await router.push('/login').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
|
||||
// Demo: the cinematic always continues into the onboarding intro page —
|
||||
// the mock backend reports "onboarded", which would otherwise route
|
||||
// straight to /login and cut the sequence short.
|
||||
{
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await checkOnboardingStatus()
|
||||
if (seenOnboarding === true) {
|
||||
await router.push('/login').catch(() => {})
|
||||
} else if (seenOnboarding === false) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
await router.push('/login').catch(() => {})
|
||||
} else {
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// Do NOT default to /onboarding/intro here. RootRedirect has retry
|
||||
// + polling + boot-screen handling; let it decide.
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
reveal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Global styles are in style.css */
|
||||
</style>
|
||||
Reference in New Issue
Block a user