archy/neode-ui/src/views/OnboardingWrapper.vue
Dorian a6c1820a83 fix: mobile onboarding viewport + filebrowser demo fixes
Onboarding:
- Fixed viewport to use dvh units with position:fixed container
- All views use scrollable glass containers that fit within viewport
- Responsive typography and spacing (mobile-first breakpoints)
- Tighter padding/margins on small screens
- RootRedirect checks localStorage first for instant redirect
- Spinner only appears after 500ms delay to avoid flash

Filebrowser:
- Fix CloudFolder null initialPath crash (watch both useNativeUI + section)
- Remove unused `host` computed (was causing TS error)
- Add mock GET /app/filebrowser/ landing page
- Increase express.json limit to 50mb

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:32:28 +00:00

640 lines
20 KiB
Vue

<template>
<div class="onb-viewport relative overflow-hidden">
<!-- Background layers with 3D perspective and zoom effect -->
<div class="bg-perspective-container">
<!-- Video background for intro/login routes (smooth transition from splash) -->
<video
v-if="useVideoBackground"
ref="videoElement"
class="bg-layer"
autoplay
loop
muted
playsinline
preload="auto"
poster="/assets/img/bg-intro.jpg"
style="width: 100%; height: 100%; object-fit: cover; object-position: center; position: absolute; inset: 0; transform: scale(1); transition: none;"
@pause.prevent="handleVideoPause"
@ended="handleVideoEnded"
>
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
</video>
<!-- Login: static background + archipelago-style glitch (no zoom) -->
<template v-else-if="isLoginRoute">
<div
class="bg-layer bg-login-static bg-fullwidth"
:style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }"
/>
<!-- Archipelago-style glitch overlays - continuous every 5s -->
<div class="login-glitch-layer login-glitch-1" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
<div class="login-glitch-layer login-glitch-2" :style="{ backgroundImage: `url('/assets/img/${loginBackground}')` }" />
<div class="login-glitch-scan" />
</template>
<!-- Static image background for other routes (with zoom on transition) -->
<div
v-else
class="bg-layer bg-zoom"
:class="{ 'bg-zoom-in': isTransitioning }"
:style="{ backgroundImage: `url('/assets/img/${currentBackground}')` }"
:key="currentBackground"
></div>
<!-- Glitch overlay layer - only for non-video, non-login background changes -->
<div v-show="isGlitching && !useVideoBackground && !isLoginRoute" class="bg-glitch-layer"></div>
</div>
<!-- Content with 3D transitions -->
<div class="perspective-container-wrapper">
<div class="perspective-container">
<RouterView v-slot="{ Component, route }">
<Transition :name="transitionName">
<div :key="route.path" class="view-wrapper">
<component :is="Component" class="view-container" />
</div>
</Transition>
</RouterView>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
const route = useRoute()
const currentBackground = ref('bg-intro.jpg')
const isGlitching = ref(false)
const isTransitioning = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const transitionName = ref('depth-forward')
// Ordered onboarding steps for direction detection
const onboardingOrder = [
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
'/onboarding/verify', '/onboarding/done', '/login'
]
// Routes that should use video background (smooth transition from splash, loops through login)
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
// Login uses video when coming from splash, or static + glitch when direct
const isLoginRoute = computed(() => route.path === '/login')
// Check if current route should use video background
const useVideoBackground = computed(() => {
return videoBackgroundRoutes.includes(route.path)
})
// Map each route to a specific background image
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
const routeBackgrounds: Record<string, string> = {
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
'/onboarding/options': 'bg-intro-4.jpg',
'/onboarding/path': 'bg-intro-3.jpg',
'/onboarding/did': 'bg-intro-5.jpg',
'/onboarding/identity': 'bg-intro-5.jpg',
'/onboarding/backup': 'bg-intro-6.jpg',
'/onboarding/verify': 'bg-intro-2.jpg',
'/onboarding/done': 'bg-intro-1.jpg',
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
}
const loginBackground = 'bg-intro-1.jpg'
// Restore video time from splash screen for seamless transition
function restoreVideoTime() {
if (videoElement.value && useVideoBackground.value) {
const savedTime = sessionStorage.getItem('video_intro_currentTime')
const wasPlaying = sessionStorage.getItem('video_intro_wasPlaying') === 'true'
const savedPlaybackRate = sessionStorage.getItem('video_intro_playbackRate')
if (savedTime) {
const time = parseFloat(savedTime)
const playbackRate = savedPlaybackRate ? parseFloat(savedPlaybackRate) : 1.0
const setVideoTime = () => {
if (videoElement.value) {
// Set playback rate first for smooth playback
videoElement.value.playbackRate = playbackRate
// Set time with slight offset to ensure smooth transition (avoid frame boundary issues)
videoElement.value.currentTime = Math.max(0, time - 0.05)
// If video was playing, ensure it continues playing immediately
if (wasPlaying) {
requestAnimationFrame(() => ensureVideoPlaying())
}
// Clean up session storage after successful restore
sessionStorage.removeItem('video_intro_currentTime')
sessionStorage.removeItem('video_intro_wasPlaying')
sessionStorage.removeItem('video_intro_playbackRate')
}
}
if (videoElement.value.readyState >= 2) {
// Video can play through current position, set time immediately
setVideoTime()
} else if (videoElement.value.readyState >= 1) {
// Video metadata loaded, set time immediately
setVideoTime()
} else {
// Wait for metadata to load
const handleLoadedMetadata = () => {
setVideoTime()
if (videoElement.value) {
videoElement.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}
videoElement.value.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
}
}
}
}
// Video play with retry - hardened for reliability
function ensureVideoPlaying(retries = 3): void {
const vid = videoElement.value
if (!vid || !useVideoBackground.value) return
if (!vid.paused) return
vid.play()
.then(() => {})
.catch(() => {
if (retries > 0) setTimeout(() => ensureVideoPlaying(retries - 1), 300)
})
}
// Ensure video plays when route uses video background
watch([useVideoBackground, route], ([useVideo]) => {
if (useVideo && videoElement.value) {
// Use requestAnimationFrame for smoother transition
requestAnimationFrame(() => {
if (videoElement.value) {
// Restore video time for seamless transition first
restoreVideoTime()
// Then ensure it's playing - use double RAF for smoother transition
requestAnimationFrame(() => {
if (videoElement.value && videoElement.value.paused) {
ensureVideoPlaying()
}
})
}
})
}
}, { immediate: true })
// Also ensure video plays on mount if route uses video
onMounted(() => {
if (useVideoBackground.value && videoElement.value) {
// Use requestAnimationFrame for smoother transition
requestAnimationFrame(() => {
if (videoElement.value) {
// Restore video time for seamless transition
restoreVideoTime()
// Use double RAF for smoother playback start
requestAnimationFrame(() => {
if (videoElement.value) {
ensureVideoPlaying()
}
})
}
})
}
})
// Watch for video element to restore time when it becomes available
watch(videoElement, (element) => {
if (element && useVideoBackground.value) {
// Use requestAnimationFrame for smoother transition
requestAnimationFrame(() => {
if (element) {
// Try to restore immediately if metadata already loaded
if (element.readyState >= 2) {
restoreVideoTime()
} else if (element.readyState >= 1) {
restoreVideoTime()
} else {
// Wait for metadata to load
const handleLoadedMetadata = () => {
restoreVideoTime()
element.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
element.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
}
}
})
}
})
// Watch route changes for background swaps, zoom, glitch, and transition direction
watch(() => route.path, (newPath, oldPath) => {
// Determine slide direction based on route order
const oldIdx = onboardingOrder.indexOf(oldPath || '')
const newIdx = onboardingOrder.indexOf(newPath)
if (oldIdx >= 0 && newIdx >= 0) {
transitionName.value = newIdx >= oldIdx ? 'slide-left' : 'slide-right'
} else {
transitionName.value = 'depth-forward'
}
const newBg = routeBackgrounds[newPath]
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
// If both old and new routes use video, don't restart video - keep it playing seamlessly
if (oldUsesVideo && newUsesVideo && videoElement.value) {
// Video continues seamlessly, just ensure it's playing
if (videoElement.value.paused) {
ensureVideoPlaying()
}
// No glitch effect, no zoom, no transitions for video-to-video
isGlitching.value = false
isTransitioning.value = false
return // Skip background change logic for video-to-video transitions
}
// If transitioning from video to non-video or vice versa, no glitch, no zoom (smooth transition)
if (oldUsesVideo || newUsesVideo) {
isGlitching.value = false
isTransitioning.value = false
}
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
if (newPath === '/login') {
currentBackground.value = 'bg-intro-1.jpg'
isTransitioning.value = false
isGlitching.value = false
return
}
// Only update if we have a defined background for this route and it's different
if (newBg && newBg !== currentBackground.value) {
// Trigger zoom animation ONLY for non-video routes (never for video)
if (!newUsesVideo && !oldUsesVideo) {
isTransitioning.value = true
// Change background
currentBackground.value = newBg
// Only trigger glitch for non-video background changes
setTimeout(() => {
isGlitching.value = true
setTimeout(() => {
isGlitching.value = false
}, 500) // Match glitch duration
// Reset zoom after glitch
isTransitioning.value = false
}, 1500 + 50) // Wait for 3D transition (1500ms) + small delay - matches splash timing
} else {
// Smooth transition for video routes - no glitch, no zoom, no effects at all
currentBackground.value = newBg
isTransitioning.value = false
isGlitching.value = false
}
}
})
// Prevent video from pausing during transitions
function handleVideoPause(event: Event) {
if (useVideoBackground.value && videoElement.value) {
event.preventDefault()
ensureVideoPlaying()
}
}
// Handle video ended - restart immediately for seamless loop
function handleVideoEnded() {
if (useVideoBackground.value && videoElement.value) {
videoElement.value.currentTime = 0
ensureVideoPlaying()
}
}
// Update body class to disable global glitch effects ONLY for video backgrounds
// This class is ONLY added on /onboarding/intro (login uses its own glitch)
// All other routes will have glitch effects enabled (normal behavior)
watch(useVideoBackground, (usesVideo) => {
if (usesVideo) {
// Add class ONLY on video background screens (/onboarding/intro, /login)
// This disables glitch effects ONLY on these screens
document.body.classList.add('video-background-active')
} else {
// Remove class on all other screens to re-enable glitch effects
document.body.classList.remove('video-background-active')
}
}, { immediate: true })
// Initialize background on mount based on current route
onMounted(() => {
const bg = routeBackgrounds[route.path]
if (bg) {
currentBackground.value = bg
}
if (useVideoBackground.value) {
isTransitioning.value = false
isGlitching.value = false
document.body.classList.add('video-background-active')
const unlock = () => {
resumeAudioContext()
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
startSynthwave()
}
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
}
})
</script>
<style scoped>
/* Fixed viewport container — locks to screen on mobile, no bounce/overflow */
.onb-viewport {
height: 100vh;
height: 100dvh;
width: 100%;
position: fixed;
inset: 0;
}
/* Wrapper to contain perspective without clipping */
.perspective-container-wrapper {
position: relative;
overflow: hidden;
height: 100%;
}
/* Perspective container for 3D depth effect */
.perspective-container {
perspective: 1200px;
perspective-origin: 50% 50%;
position: relative;
height: 100%;
overflow: hidden;
}
/* View wrapper - allows smooth transitions with absolute positioning */
.view-wrapper {
position: absolute;
inset: 0;
transform-style: preserve-3d;
backface-visibility: hidden;
will-change: transform, opacity;
overflow: hidden;
}
.view-container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* 2advanced-style: fluid depth transitions */
.depth-forward-enter-active.view-wrapper,
.depth-forward-leave-active.view-wrapper {
transition: all 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.depth-forward-enter-from.view-wrapper {
opacity: 0;
transform: translateZ(-1200px) scale(0.6);
filter: blur(10px);
}
.depth-forward-enter-to.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-forward-leave-from.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-forward-leave-to.view-wrapper {
opacity: 0;
transform: translateZ(500px) scale(1.25);
filter: blur(10px);
}
/* Horizontal slide transitions (direction-aware onboarding steps) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper,
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
}
.slide-left-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
.slide-left-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
/* Background zoom - 2advanced fluid */
.bg-zoom {
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: scale(1);
}
.bg-zoom-in {
transform: scale(1.15);
}
/* Subtle 3D tilt - 2advanced layered depth */
@media (min-width: 768px) {
.depth-forward-enter-from.view-wrapper {
transform: translateZ(-1200px) scale(0.6) rotateX(6deg);
}
.depth-forward-leave-to.view-wrapper {
transform: translateZ(500px) scale(1.25) rotateX(-4deg);
}
}
/* Background 3D container */
.bg-perspective-container {
position: fixed;
inset: 0;
perspective: 1000px;
perspective-origin: 50% 50%;
z-index: -10;
overflow: hidden;
min-width: 100vw;
width: 100vw;
}
/* Full width background on every screen */
.bg-fullwidth {
min-width: 100vw;
width: 100vw;
background-size: cover;
background-position: center center;
}
.bg-layer {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transform-style: preserve-3d;
backface-visibility: hidden;
}
/* Video background styling - video element itself has bg-layer class */
.bg-layer video,
video.bg-layer {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
position: absolute;
inset: 0;
}
.bg-static {
opacity: 1;
transform: translateZ(0) scale(1);
}
/* Login: static background - just there, no zoom */
.bg-login-static {
opacity: 1;
transform: none;
}
/* Archipelago-style glitch overlays for login - continuous every 5s */
.login-glitch-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
}
.login-glitch-1 {
mix-blend-mode: screen;
filter: hue-rotate(22deg) saturate(1.35);
animation: login-glitch-shift 5s steps(10, end) infinite;
}
.login-glitch-2 {
mix-blend-mode: screen;
filter: hue-rotate(-30deg) saturate(1.45);
animation: login-glitch-shift-2 5s steps(9, end) infinite;
}
.login-glitch-scan {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
background:
linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0) 60%),
repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 2px, rgba(0,0,0,0) 2px 4px),
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.35) 100%);
opacity: 0;
animation: login-glitch-scan 5s ease-out infinite;
}
@keyframes login-glitch-shift {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
82.1% { opacity: 0.22; }
84% { transform: translate(6px,-2px); clip-path: inset(8% 0 70% 0); }
86% { transform: translate(-5px,2px); clip-path: inset(42% 0 40% 0); }
88% { transform: translate(3px,0); clip-path: inset(68% 0 10% 0); }
91% { transform: translate(-4px,3px); clip-path: inset(18% 0 60% 0); }
93% { transform: translate(5px,-3px); clip-path: inset(55% 0 20% 0); }
95% { transform: translate(-3px,1px); clip-path: inset(10% 0 80% 0); }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes login-glitch-shift-2 {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
82.1% { opacity: 0.18; }
84% { transform: translate(-6px,2px); clip-path: inset(12% 0 65% 0); }
86% { transform: translate(5px,-1px) skewX(0.6deg); clip-path: inset(36% 0 42% 0); }
89% { transform: translate(-3px,2px); clip-path: inset(72% 0 8% 0); }
92% { transform: translate(4px,-3px); clip-path: inset(22% 0 58% 0); }
95% { transform: translate(-4px,1px); clip-path: inset(50% 0 26% 0); }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes login-glitch-scan {
0%, 82% { opacity: 0; transform: translateY(-20%); }
84% { opacity: 0.4; }
90% { opacity: 0.28; }
100% { opacity: 0; transform: translateY(115%); }
}
/* Glitch overlay layer */
.bg-glitch-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
background: rgba(255, 255, 255, 0.1);
mix-blend-mode: overlay;
opacity: 0;
animation: bg-glitch-flash 500ms ease-in-out;
}
@keyframes bg-glitch-flash {
0%, 100% {
opacity: 0;
transform: translateX(0);
}
10% {
opacity: 0.3;
transform: translateX(-3px);
}
20% {
opacity: 0;
transform: translateX(3px);
}
30% {
opacity: 0.4;
transform: translateX(-2px);
}
40% {
opacity: 0;
transform: translateX(2px);
}
50% {
opacity: 0.2;
transform: translateX(-1px);
}
60% {
opacity: 0;
transform: translateX(1px);
}
}
</style>