Enhance UI components and improve user notifications
- Updated App.vue to include a toast notification system for new messages, enhancing user engagement. - Modified SplashScreen.vue to streamline the intro text display with improved typing effects. - Added Montserrat font styles in style.css for better typography across the application. - Improved controller navigation in useControllerNav.ts to support enhanced focus management and sound feedback. - Updated routing logic in index.ts to redirect authenticated users from the login page to the home page. - Enhanced the Login.vue view with transition effects for a smoother user experience during login and setup processes.
This commit is contained in:
parent
1073d9fd2c
commit
1b05b5b8f1
BIN
intro-typing.mp3
Normal file
BIN
intro-typing.mp3
Normal file
Binary file not shown.
BIN
loop-start.mp3
Normal file
BIN
loop-start.mp3
Normal file
Binary file not shown.
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.8432ene9gn8"
|
||||
"revision": "0.3i6g4o6lft4"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
BIN
neode-ui/public/assets/audio/arrows.mp3
Normal file
BIN
neode-ui/public/assets/audio/arrows.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/cosmic-updrift.mp3
Normal file
BIN
neode-ui/public/assets/audio/cosmic-updrift.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/enter.mp3
Normal file
BIN
neode-ui/public/assets/audio/enter.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/intro-typing.mp3
Normal file
BIN
neode-ui/public/assets/audio/intro-typing.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/typing.mp3
Normal file
BIN
neode-ui/public/assets/audio/typing.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/winning-is-invisible.mp3
Normal file
BIN
neode-ui/public/assets/audio/winning-is-invisible.mp3
Normal file
Binary file not shown.
BIN
neode-ui/public/assets/audio/woosh.mp3
Normal file
BIN
neode-ui/public/assets/audio/woosh.mp3
Normal file
Binary file not shown.
@ -20,11 +20,35 @@
|
||||
|
||||
<!-- PWA Update Prompt -->
|
||||
<PWAUpdatePrompt />
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
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'
|
||||
@ -32,11 +56,27 @@ import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import { useControllerNav } from '@/composables/useControllerNav'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
const appStore = useAppStore()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
|
||||
useControllerNav()
|
||||
|
||||
// Start/stop message polling when auth state changes
|
||||
watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
messageToast.startPolling()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '' }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||
@ -84,6 +124,7 @@ async function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
sessionStorage.setItem('archipelago_from_splash', '1')
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
|
||||
@ -52,27 +52,19 @@
|
||||
<div class="font-mono text-white px-4 sm:px-5 max-w-[95vw] sm:max-w-[90vw] md:max-w-[1200px] text-base sm:text-lg md:text-[24px] leading-relaxed break-words">
|
||||
<div v-if="showLine1" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine1 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine1 }">
|
||||
In the future there will be 3 types of humans
|
||||
</span>
|
||||
<span class="text-white break-words">{{ displayLine1 }}</span><span v-if="isTypingLine1" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine2" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine2 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine2 }">
|
||||
Government Employees
|
||||
</span>
|
||||
<span class="text-white break-words">{{ displayLine2 }}</span><span v-if="isTypingLine2" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine3" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine3 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine3 }">
|
||||
Corporate Employees
|
||||
</span>
|
||||
<span class="text-white break-words">{{ displayLine3 }}</span><span v-if="isTypingLine3" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div v-if="showLine4" class="flex items-start mb-8 sm:mb-12 opacity-0" :class="{ 'opacity-100': showLine4 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine4 }">
|
||||
And Noderunners...
|
||||
</span>
|
||||
<span class="text-white break-words">{{ displayLine4 }}</span><span v-if="isTypingLine4" class="intro-typing-caret" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -107,7 +99,7 @@
|
||||
<!-- Skip Button -->
|
||||
<button
|
||||
v-if="!alienIntroComplete"
|
||||
@click="skipIntro"
|
||||
@click="handleSkipClick"
|
||||
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
|
||||
>
|
||||
Skip Intro
|
||||
@ -117,12 +109,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { playIntroTyping, playLoopStart, resumeAudioContext, startSynthwave, stopIntroTyping } from '@/composables/useLoginSounds'
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const INTRO_LINES = [
|
||||
'In the future there will be 3 types of humans',
|
||||
'Government Employees',
|
||||
'Corporate Employees',
|
||||
'And Noderunners...',
|
||||
] as const
|
||||
const MS_PER_CHAR = 55
|
||||
const BLINK_AFTER_TYPING = 1500
|
||||
|
||||
const showSplash = ref(true)
|
||||
const backgroundOpacity = ref(0)
|
||||
const alienIntroComplete = ref(false)
|
||||
@ -135,11 +137,16 @@ const showLine1 = ref(false)
|
||||
const showLine2 = ref(false)
|
||||
const showLine3 = ref(false)
|
||||
const showLine4 = ref(false)
|
||||
const typingLine1 = ref(false)
|
||||
const typingLine2 = ref(false)
|
||||
const typingLine3 = ref(false)
|
||||
const typingLine4 = ref(false)
|
||||
const displayLine1 = ref('')
|
||||
const displayLine2 = ref('')
|
||||
const displayLine3 = ref('')
|
||||
const displayLine4 = ref('')
|
||||
const isTypingLine1 = ref(false)
|
||||
const isTypingLine2 = ref(false)
|
||||
const isTypingLine3 = ref(false)
|
||||
const isTypingLine4 = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
let introTypingTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Ensure video plays continuously from Welcome Noderunner through logo
|
||||
watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
@ -206,18 +213,31 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
// Check if user has seen intro
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
|
||||
function handleSkipClick() {
|
||||
resumeAudioContext()
|
||||
skipIntro()
|
||||
}
|
||||
|
||||
function skipIntro() {
|
||||
// Jump to "Welcome Noderunner" part
|
||||
if (introTypingTimeout) {
|
||||
clearTimeout(introTypingTimeout)
|
||||
introTypingTimeout = null
|
||||
}
|
||||
alienIntroComplete.value = true
|
||||
fadeAlienIntro.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
stopIntroTyping()
|
||||
playLoopStart()
|
||||
startSynthwave()
|
||||
|
||||
// Stop alien intro typing animations
|
||||
typingLine1.value = false
|
||||
typingLine2.value = false
|
||||
typingLine3.value = false
|
||||
typingLine4.value = false
|
||||
// Stop alien intro typing and any playing typing sound
|
||||
stopIntroTyping()
|
||||
isTypingLine1.value = false
|
||||
isTypingLine2.value = false
|
||||
isTypingLine3.value = false
|
||||
isTypingLine4.value = false
|
||||
|
||||
// Start background fade in at 0.3 opacity when welcome appears
|
||||
setTimeout(() => {
|
||||
@ -259,81 +279,90 @@ function skipIntro() {
|
||||
}
|
||||
|
||||
function startAlienIntro() {
|
||||
// Line 1 - types and blinks
|
||||
setTimeout(() => {
|
||||
function typeLine(
|
||||
lineIndex: number,
|
||||
displayRef: { value: string },
|
||||
isTypingRef: { value: boolean },
|
||||
onDone: () => void
|
||||
) {
|
||||
const text = INTRO_LINES[lineIndex]!
|
||||
let i = 0
|
||||
displayRef.value = ''
|
||||
isTypingRef.value = true
|
||||
|
||||
function tick() {
|
||||
if (i === 0) {
|
||||
playIntroTyping()
|
||||
}
|
||||
if (i < text.length) {
|
||||
displayRef.value = text.slice(0, i + 1)
|
||||
i++
|
||||
introTypingTimeout = setTimeout(tick, MS_PER_CHAR)
|
||||
} else {
|
||||
stopIntroTyping()
|
||||
isTypingRef.value = false
|
||||
introTypingTimeout = setTimeout(onDone, BLINK_AFTER_TYPING)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
function scheduleLine1() {
|
||||
showLine1.value = true
|
||||
typingLine1.value = true
|
||||
}, 500)
|
||||
typeLine(0, displayLine1, isTypingLine1, scheduleLine2)
|
||||
}
|
||||
|
||||
// Line 2 - wait for line 1 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine1.value = false
|
||||
function scheduleLine2() {
|
||||
showLine2.value = true
|
||||
typingLine2.value = true
|
||||
}, 6000)
|
||||
typeLine(1, displayLine2, isTypingLine2, scheduleLine3)
|
||||
}
|
||||
|
||||
// Line 3 - wait for line 2 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine2.value = false
|
||||
function scheduleLine3() {
|
||||
showLine3.value = true
|
||||
typingLine3.value = true
|
||||
}, 11500)
|
||||
typeLine(2, displayLine3, isTypingLine3, scheduleLine4)
|
||||
}
|
||||
|
||||
// Line 4 - wait for line 3 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine3.value = false
|
||||
function scheduleLine4() {
|
||||
showLine4.value = true
|
||||
typingLine4.value = true
|
||||
}, 17000)
|
||||
|
||||
// Fade out alien intro - wait for line 4 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine4.value = false
|
||||
typeLine(3, displayLine4, isTypingLine4, () => {
|
||||
isTypingLine4.value = false
|
||||
fadeAlienIntro.value = true
|
||||
}, 22500)
|
||||
introTypingTimeout = setTimeout(showWelcomePhase, 800)
|
||||
})
|
||||
}
|
||||
|
||||
// Show welcome and start video immediately
|
||||
setTimeout(() => {
|
||||
function showWelcomePhase() {
|
||||
alienIntroComplete.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
// Start video immediately when welcome appears
|
||||
stopIntroTyping()
|
||||
playLoopStart()
|
||||
startSynthwave()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
}, 23300)
|
||||
|
||||
// Start background fade in at 0.3 opacity when welcome appears
|
||||
setTimeout(() => {
|
||||
backgroundOpacity.value = 0.3
|
||||
}, 23300)
|
||||
|
||||
// Fade out welcome - typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s) = 4.85s
|
||||
setTimeout(() => {
|
||||
introTypingTimeout = setTimeout(() => {
|
||||
fadeWelcome.value = true
|
||||
typingWelcome.value = false
|
||||
}, 28150)
|
||||
}, 4850)
|
||||
|
||||
// Show logo - background stays at 0.3 opacity
|
||||
setTimeout(() => {
|
||||
introTypingTimeout = setTimeout(() => {
|
||||
showLogo.value = true
|
||||
}, 29000)
|
||||
}, 5500)
|
||||
|
||||
// Hide welcome after logo starts appearing
|
||||
setTimeout(() => {
|
||||
introTypingTimeout = setTimeout(() => {
|
||||
showWelcome.value = false
|
||||
}, 30500)
|
||||
}, 6000)
|
||||
|
||||
// Fade background to full opacity just before completing (for smooth transition to modal)
|
||||
setTimeout(() => {
|
||||
introTypingTimeout = setTimeout(() => {
|
||||
backgroundOpacity.value = 1
|
||||
}, 33000)
|
||||
}, 9000)
|
||||
|
||||
// Complete splash with smooth transition
|
||||
setTimeout(() => {
|
||||
// Store final video time right before unmounting
|
||||
introTypingTimeout = setTimeout(() => {
|
||||
if (videoElement.value && !videoElement.value.paused) {
|
||||
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', 'true')
|
||||
@ -342,18 +371,35 @@ function startAlienIntro() {
|
||||
document.body.classList.add('splash-complete')
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
emit('complete')
|
||||
}, 34500)
|
||||
}, 9500)
|
||||
}
|
||||
|
||||
introTypingTimeout = setTimeout(scheduleLine1, 500)
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (seenIntro) {
|
||||
// Skip intro if already seen
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
emit('complete')
|
||||
} else {
|
||||
// Play intro
|
||||
startAlienIntro()
|
||||
// Unlock audio on first user interaction (required for autoplay in most browsers)
|
||||
const unlock = () => {
|
||||
resumeAudioContext()
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (introTypingTimeout) {
|
||||
clearTimeout(introTypingTimeout)
|
||||
introTypingTimeout = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -465,6 +511,23 @@ onMounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Intro typing cursor - block style, cyan blink (matches original typing-text caret) */
|
||||
.intro-typing-caret {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
height: 1.2em;
|
||||
background: #00ffff;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: intro-caret-blink 0.5s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes intro-caret-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Ensure text wraps smoothly on mobile */
|
||||
.font-mono {
|
||||
word-wrap: break-word;
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Controller / gamepad-style navigation for Archipelago.
|
||||
* Supports Rii X8 (keyboard/d-pad) and standard gamepads.
|
||||
* - Arrow keys / d-pad: navigate between focusable elements
|
||||
* - Enter / A button: activate
|
||||
* - Escape / B button: back
|
||||
* - Game-like navigation sounds and visual feedback
|
||||
* Xbox-style controller / gamepad navigation for Archipelago.
|
||||
* - Left: Go to side menu only when on leftmost main content
|
||||
* - Right: Go to main content (from side menu)
|
||||
* - Main: spatial/grid navigation (up/down/left/right like a game)
|
||||
* - Enter enters container's inner actions; actions get celebratory sound
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
@ -19,6 +20,7 @@ const FOCUSABLE_SELECTOR = [
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||
@ -27,24 +29,94 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
|
||||
)
|
||||
}
|
||||
|
||||
function playNavSound(type: 'move' | 'select' | 'back' = 'move') {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
gain.gain.value = 0.08
|
||||
osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440
|
||||
osc.type = 'sine'
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.05)
|
||||
} catch {
|
||||
// Audio not supported or blocked
|
||||
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
||||
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
||||
if (!container) return []
|
||||
return getFocusableElements(container)
|
||||
}
|
||||
|
||||
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
if (!el) return false
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
return !!container && container !== el
|
||||
}
|
||||
|
||||
/** Spatial navigation: find nearest focusable in direction (game-style grid) */
|
||||
function findNearestInDirection(
|
||||
from: HTMLElement,
|
||||
candidates: HTMLElement[],
|
||||
direction: 'up' | 'down' | 'left' | 'right'
|
||||
): HTMLElement | null {
|
||||
const fromRect = from.getBoundingClientRect()
|
||||
const fromCenterX = fromRect.left + fromRect.width / 2
|
||||
const fromCenterY = fromRect.top + fromRect.height / 2
|
||||
const threshold = 50 // px overlap allowed
|
||||
|
||||
const filtered = candidates.filter((el) => {
|
||||
if (el === from) return false
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return r.right <= fromRect.left + threshold
|
||||
case 'right':
|
||||
return r.left >= fromRect.right - threshold
|
||||
case 'up':
|
||||
return r.bottom <= fromRect.top + threshold
|
||||
case 'down':
|
||||
return r.top >= fromRect.bottom - threshold
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
// Pick best: most overlap on perpendicular axis, then closest
|
||||
const scored = filtered.map((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const centerX = r.left + r.width / 2
|
||||
const centerY = r.top + r.height / 2
|
||||
|
||||
let overlap: number
|
||||
let dist: number
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||
dist = Math.abs(centerX - fromCenterX)
|
||||
break
|
||||
case 'up':
|
||||
case 'down':
|
||||
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||
dist = Math.abs(centerY - fromCenterY)
|
||||
break
|
||||
default:
|
||||
overlap = 0
|
||||
dist = Infinity
|
||||
}
|
||||
return { el, overlap, dist }
|
||||
})
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
return a.dist - b.dist
|
||||
})
|
||||
return scored[0]?.el ?? null
|
||||
}
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useControllerStore()
|
||||
const isControllerActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
@ -69,7 +141,6 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
if (!navKeys.includes(e.key)) return
|
||||
|
||||
// Ignore when typing in inputs
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (e.key !== 'Escape') return
|
||||
@ -78,7 +149,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const root = containerRef?.value ?? document
|
||||
const focusable = getFocusableElements(root)
|
||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// --- ESCAPE ---
|
||||
if (e.key === 'Escape') {
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
@ -86,21 +159,67 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
||||
if (container && container.tabIndex >= 0) {
|
||||
playNavSound('back')
|
||||
container.focus()
|
||||
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
||||
if (isDetailPage) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const firstSidebar = sidebarEls[0]
|
||||
if (firstSidebar && isInZone(activeEl, 'main')) {
|
||||
playNavSound('back')
|
||||
firstSidebar.focus()
|
||||
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ENTER ---
|
||||
if (e.key === 'Enter') {
|
||||
if (currentIndex >= 0 && focusable[currentIndex]) {
|
||||
playNavSound('select')
|
||||
;(focusable[currentIndex] as HTMLElement).click()
|
||||
const el = focusable[currentIndex] as HTMLElement
|
||||
|
||||
if (el.hasAttribute('data-controller-container')) {
|
||||
const inner = getInnerFocusables(el)
|
||||
const firstInner = inner[0]
|
||||
if (firstInner) {
|
||||
playNavSound('action')
|
||||
firstInner.focus()
|
||||
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playNavSound('action')
|
||||
el.click()
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ARROWS ---
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
@ -108,9 +227,87 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const mainEls = getElementsInZone('main')
|
||||
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
||||
|
||||
// Right: from sidebar → main
|
||||
const firstMain = mainEls[0]
|
||||
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
|
||||
playNavSound('move')
|
||||
firstMain.focus()
|
||||
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Main zone: spatial navigation (game-style grid)
|
||||
if (hasZones && isInZone(activeEl, 'main')) {
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, mainEls, dir)
|
||||
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// No element in that direction: Left from leftmost → sidebar
|
||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
||||
const lastSidebar = sidebarEls[sidebarEls.length - 1]
|
||||
if (lastSidebar) {
|
||||
playNavSound('move')
|
||||
lastSidebar.focus()
|
||||
lastSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inside container: spatial nav among inner elements
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
if (container) {
|
||||
const inner = getInnerFocusables(container)
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar: linear up/down
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const idx = sidebarEls.indexOf(activeEl)
|
||||
if (idx >= 0) {
|
||||
const isDown = e.key === 'ArrowDown'
|
||||
const nextIdx = isDown ? Math.min(idx + 1, sidebarEls.length - 1) : Math.max(idx - 1, 0)
|
||||
const next = sidebarEls[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: linear navigation
|
||||
let nextIndex = currentIndex
|
||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
||||
|
||||
if (focusable.length === 0) return
|
||||
|
||||
if (currentIndex < 0) {
|
||||
@ -126,6 +323,12 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/') && next.tagName === 'A') {
|
||||
router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
199
neode-ui/src/composables/useLoginSounds.ts
Normal file
199
neode-ui/src/composables/useLoginSounds.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Login screen audio: intro loop (MP3) + transition sounds.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0,
|
||||
dest: AudioNode = ctx.destination
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(dest)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||||
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||
|
||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in */
|
||||
export function playLoopStart() {
|
||||
try {
|
||||
const audio = new Audio(LOOP_START_URL)
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
||||
export function resumeAudioContext() {
|
||||
const ctx = getContext()
|
||||
if (ctx?.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Start intro loop - Cosmic Updrift (free royalty) */
|
||||
export function startSynthwave() {
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
try {
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
stopSynthwave()
|
||||
|
||||
const audio = new Audio(INTRO_AUDIO_URL)
|
||||
audio.loop = true
|
||||
|
||||
const ctx = ctxOrNull
|
||||
const source = ctx.createMediaElementSource(audio)
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = 0.25
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
introGain = gainNode
|
||||
introAudio = audio
|
||||
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Stop intro loop (call on login success) */
|
||||
export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(1, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
introAudio?.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}, 220)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whoosh transition on successful login */
|
||||
export function playLoginSuccessWhoosh() {
|
||||
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||||
woosh.volume = 0.5
|
||||
woosh.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
|
||||
export function playTypingSound() {
|
||||
const audio = new Audio('/assets/audio/typing.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
|
||||
let introTypingAudio: HTMLAudioElement | null = null
|
||||
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
|
||||
|
||||
export function playIntroTyping() {
|
||||
stopIntroTyping()
|
||||
introTypingAudio = new Audio(INTRO_TYPING_URL)
|
||||
introTypingAudio.volume = 0.5
|
||||
introTypingAudio.loop = true
|
||||
introTypingAudio.play().catch(() => {})
|
||||
}
|
||||
|
||||
export function stopIntroTyping() {
|
||||
if (introTypingAudio) {
|
||||
introTypingAudio.pause()
|
||||
introTypingAudio.currentTime = 0
|
||||
introTypingAudio = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Typing tick - for dashboard welcome typing (typing.mp3) */
|
||||
let typingTickPool: HTMLAudioElement[] = []
|
||||
const TYPING_TICK_POOL_SIZE = 5
|
||||
|
||||
function getTypingTick(): HTMLAudioElement {
|
||||
if (typingTickPool.length === 0) {
|
||||
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
|
||||
const a = new Audio('/assets/audio/typing.mp3')
|
||||
a.volume = 0.4
|
||||
typingTickPool.push(a)
|
||||
}
|
||||
}
|
||||
const a = typingTickPool.shift()!
|
||||
typingTickPool.push(a)
|
||||
return a
|
||||
}
|
||||
|
||||
export function playTypingTick() {
|
||||
const a = getTypingTick()
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
export function playDashboardLoadOomph() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
|
||||
// Soft layered thud - sine only, smooth attack/decay
|
||||
const layers = [
|
||||
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
|
||||
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
|
||||
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
|
||||
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
|
||||
]
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const L = layers[i]!
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = L.freq
|
||||
g.gain.setValueAtTime(0, t)
|
||||
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
osc.start(t + i * 0.01)
|
||||
osc.stop(t + L.dur)
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,7 @@ export function useMessageToast() {
|
||||
show: true,
|
||||
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
|
||||
70
neode-ui/src/composables/useNavSounds.ts
Normal file
70
neode-ui/src/composables/useNavSounds.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Epic interface sounds for controller/keyboard navigation.
|
||||
* Layered synthesis - cool, impactful, celebratory for actions.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') {
|
||||
if (type === 'move') {
|
||||
const audio = new Audio('/assets/audio/arrows.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
if (type === 'select' || type === 'action') {
|
||||
const audio = new Audio('/assets/audio/enter.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'back': {
|
||||
playTone(ctx, 440, 0.06, 0.08, 'sine')
|
||||
playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03)
|
||||
playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const router = createRouter({
|
||||
@ -55,6 +56,7 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/dashboard/', redirect: '/dashboard' },
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
@ -133,9 +135,9 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
// Allow all public routes (login, onboarding) without auth check
|
||||
if (isPublic) {
|
||||
// If already authenticated and trying to access login, redirect to dashboard
|
||||
// If already authenticated and trying to access login, redirect to home
|
||||
if (to.path === '/login' && store.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
next({ name: 'home' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
@ -169,5 +171,19 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
|
||||
router.afterEach((to) => {
|
||||
if (to.path === '/dashboard' || to.path === '/dashboard/') {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const homeLink = document.querySelector<HTMLAnchorElement>(
|
||||
'[data-controller-zone="sidebar"] a[href="/dashboard"], [data-controller-zone="sidebar"] a[href="/dashboard/"]'
|
||||
)
|
||||
if (homeLink) homeLink.focus()
|
||||
}, 150)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
32
neode-ui/src/stores/loginTransition.ts
Normal file
32
neode-ui/src/stores/loginTransition.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Signals that we just logged in - Dashboard uses this for zoom + oomph */
|
||||
export const useLoginTransitionStore = defineStore('loginTransition', () => {
|
||||
const justLoggedIn = ref(false)
|
||||
/** Show empty welcome block until typing starts (hide static text) */
|
||||
const pendingWelcomeTyping = ref(false)
|
||||
/** Trigger welcome typing on Home - set true after dashboard animation finishes */
|
||||
const startWelcomeTyping = ref(false)
|
||||
|
||||
function setJustLoggedIn(value: boolean) {
|
||||
justLoggedIn.value = value
|
||||
}
|
||||
|
||||
function setPendingWelcomeTyping(value: boolean) {
|
||||
pendingWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
function setStartWelcomeTyping(value: boolean) {
|
||||
startWelcomeTyping.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
justLoggedIn,
|
||||
setJustLoggedIn,
|
||||
pendingWelcomeTyping,
|
||||
setPendingWelcomeTyping,
|
||||
startWelcomeTyping,
|
||||
setStartWelcomeTyping,
|
||||
}
|
||||
})
|
||||
@ -2,10 +2,38 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Controller / keyboard navigation - game-like focus ring */
|
||||
/* Montserrat - header font (used in neode present) */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Controller / keyboard navigation - soft glow, hover-style (not yellow button) */
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.8);
|
||||
outline-offset: 2px;
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25),
|
||||
0 0 16px rgba(120, 180, 255, 0.2),
|
||||
0 0 32px rgba(100, 160, 255, 0.1);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Containers get a subtle inner glow when focused */
|
||||
[data-controller-container]:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3),
|
||||
0 0 24px rgba(120, 180, 255, 0.15),
|
||||
0 0 48px rgba(100, 160, 255, 0.08),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Global glassmorphism utilities */
|
||||
@ -67,6 +95,7 @@
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* Toast transition */
|
||||
@ -525,6 +554,19 @@ body::after {
|
||||
animation: bg-glitch-scan-repeat 5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Dashboard: full viewport width, no letterboxing */
|
||||
body.dashboard-active {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.dashboard-active .dashboard-view .bg-perspective-container {
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Disable glitch effect on dashboard */
|
||||
.dashboard-view ~ body::before,
|
||||
.dashboard-view ~ body::after,
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view">
|
||||
<!-- Background container with 3D perspective -->
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - default -->
|
||||
<!-- Background - default (zoom animates on this layer, not container, to avoid letterboxing) -->
|
||||
<div
|
||||
ref="bgDefault"
|
||||
class="bg-layer"
|
||||
:class="{ 'bg-transitioning-out': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground }"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="[
|
||||
{ 'bg-transitioning-out': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground },
|
||||
{ 'zoom-reveal-bg': showZoomIn }
|
||||
]"
|
||||
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
|
||||
/>
|
||||
<!-- Background - alternate for app details and Web5 -->
|
||||
<div
|
||||
ref="bgAlt"
|
||||
class="bg-layer"
|
||||
class="bg-layer bg-fullwidth"
|
||||
:class="{ 'bg-transitioning-in': showAltBackground || showWeb5Background || showNetworkBackground || showSettingsBackground || showMyAppsBackground || showAppStoreBackground || showCloudBackground || showHomeBackground }"
|
||||
:style="{ backgroundImage: `url(/assets/img/${altBackgroundImage})` }"
|
||||
/>
|
||||
@ -31,8 +34,31 @@
|
||||
class="bg-glitch-scan"
|
||||
:class="{ 'glitch-active': isGlitching }"
|
||||
/>
|
||||
<!-- Continuous glitch/flash overlays - same as login, every 5s -->
|
||||
<div
|
||||
class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth"
|
||||
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
|
||||
/>
|
||||
<div
|
||||
class="dashboard-glitch-layer dashboard-glitch-2 bg-fullwidth"
|
||||
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
|
||||
/>
|
||||
<div class="dashboard-glitch-scan" />
|
||||
</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 - 0.3 opacity default, 0.8 opacity for Web5, Network, and Settings -->
|
||||
<div
|
||||
class="fixed inset-0 transition-opacity duration-500 pointer-events-none"
|
||||
@ -40,10 +66,15 @@
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
<!-- Sidebar - Desktop Only -->
|
||||
<aside class="hidden md:flex w-[256px] border-r border-glass-border shadow-glass-sm flex-shrink-0 relative flex-col" style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<!-- Sidebar - Desktop Only, animates in at end with separate parts -->
|
||||
<aside
|
||||
data-controller-zone="sidebar"
|
||||
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col"
|
||||
:class="{ 'sidebar-animate': showZoomIn }"
|
||||
>
|
||||
<div class="sidebar-shell">
|
||||
<div class="sidebar-inner">
|
||||
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0">
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
@ -51,13 +82,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="space-y-2">
|
||||
<nav class="sidebar-nav space-y-2 p-6 pt-4">
|
||||
<RouterLink
|
||||
v-for="item in desktopNavItems"
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
exact-active-class="nav-tab-active"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@ -72,18 +104,15 @@
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Controller indicator - Desktop sidebar -->
|
||||
<div class="px-6 pb-2">
|
||||
<div class="sidebar-controller px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
|
||||
<!-- User Section - Desktop Only -->
|
||||
<div class="p-6">
|
||||
<div class="sidebar-logout p-6">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
@ -91,10 +120,16 @@
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-hidden relative pb-20 md:pb-0">
|
||||
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
||||
<main
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
>
|
||||
<!-- Connection Status Banner -->
|
||||
<div v-if="isOffline && !store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-yellow-500">
|
||||
<div class="flex items-center gap-2 text-yellow-200">
|
||||
@ -117,34 +152,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New message toast (top right, glassmorphic) -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace - glass piece 3 -->
|
||||
<div
|
||||
v-if="showAppsTabs"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pt-4 pb-2"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pt-4 pb-2 glass-piece"
|
||||
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
>
|
||||
<div class="glass-card p-2 rounded-lg flex gap-2 relative">
|
||||
@ -182,10 +194,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Network/Cloud -->
|
||||
<!-- Persistent Mobile Tabs for Network/Cloud - glass piece 4 -->
|
||||
<div
|
||||
v-if="showNetworkTabs"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pt-4 pb-2"
|
||||
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pt-4 pb-2 glass-piece"
|
||||
:class="{ 'glass-throw-mobile-tabs-2': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
|
||||
:style="{ top: showAppsTabs ? '80px' : '0' }"
|
||||
>
|
||||
@ -224,7 +237,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perspective-container-wrapper" :class="{ 'pt-40': showAppsTabs && showNetworkTabs, 'pt-20': showAppsTabs !== showNetworkTabs }">
|
||||
<div class="perspective-container-wrapper glass-piece" :class="{ 'pt-40': showAppsTabs && showNetworkTabs, 'pt-20': showAppsTabs !== showNetworkTabs, 'glass-throw-content': showZoomIn && !isHomeRoute }">
|
||||
<div class="perspective-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)">
|
||||
@ -246,11 +259,12 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Mobile Bottom Tab Bar -->
|
||||
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
|
||||
<nav
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);"
|
||||
>
|
||||
<div class="flex justify-around items-center px-2 py-3 relative">
|
||||
@ -288,18 +302,21 @@
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const showZoomIn = ref(false)
|
||||
|
||||
// Background swap for app details, Web5, Network, Settings, My Apps, App Store, Cloud, and Home
|
||||
const showAltBackground = ref(false)
|
||||
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'))
|
||||
@ -528,8 +545,32 @@ function updateNetworkTabIndicator() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.body.classList.add('dashboard-active')
|
||||
if (loginTransition.justLoggedIn) {
|
||||
playDashboardLoadOomph()
|
||||
showZoomIn.value = true
|
||||
loginTransition.setPendingWelcomeTyping(true)
|
||||
loginTransition.setJustLoggedIn(false)
|
||||
// Trigger glitches during background reveal (0.5s, 1.2s, 2s into 2.8s zoom)
|
||||
const triggerRevealGlitch = () => {
|
||||
isGlitching.value = true
|
||||
setTimeout(() => { isGlitching.value = false }, 380)
|
||||
}
|
||||
setTimeout(triggerRevealGlitch, 500)
|
||||
setTimeout(triggerRevealGlitch, 1200)
|
||||
setTimeout(triggerRevealGlitch, 2000)
|
||||
// Keep glass-throw active long enough for sidebar (last to animate, ~7.5s)
|
||||
setTimeout(() => {
|
||||
showZoomIn.value = false
|
||||
}, 8000)
|
||||
// Trigger welcome typing on Home after main content animation finishes
|
||||
setTimeout(() => {
|
||||
loginTransition.setStartWelcomeTyping(true)
|
||||
loginTransition.setPendingWelcomeTyping(false)
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
updateTabBarHeight()
|
||||
messageToast.startPolling()
|
||||
updateAppsTabIndicator()
|
||||
updateNetworkTabIndicator()
|
||||
|
||||
@ -541,8 +582,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-active')
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
messageToast.stopPolling()
|
||||
})
|
||||
|
||||
// Watch route changes to update indicator position
|
||||
@ -786,6 +827,362 @@ function getTransitionName(currentRoute: any) {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 2advanced-style: fluid, cinematic, layered motion */
|
||||
/* Strong easeInOut - smooth acceleration/deceleration, no bounce */
|
||||
|
||||
/* Background - zoom in from depth with motion blur */
|
||||
.zoom-reveal-bg {
|
||||
animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.zoom-reveal-bg {
|
||||
opacity: 0;
|
||||
transform: scale(0.15);
|
||||
filter: blur(24px);
|
||||
}
|
||||
|
||||
@keyframes zoom-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.15);
|
||||
filter: blur(24px);
|
||||
}
|
||||
35% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.5);
|
||||
filter: blur(20px);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.88);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 2advanced-style glass assembly - fluid, layered, deliberate timing */
|
||||
.glass-throw-active {
|
||||
perspective: 1400px;
|
||||
}
|
||||
|
||||
.glass-piece {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Sidebar - animates in at end with separate parts (like cards) */
|
||||
.sidebar-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-right: 1px solid transparent;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-shell {
|
||||
animation: sidebar-shell-fly 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 5.2s;
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@keyframes sidebar-shell-fly {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-inner {
|
||||
animation: sidebar-inner-draw 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 6.1s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-inner-draw {
|
||||
0% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
20% { opacity: 1; }
|
||||
100% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-nav-item {
|
||||
animation: sidebar-nav-item-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(6.3s + var(--nav-stagger, 0) * 0.06s);
|
||||
}
|
||||
|
||||
@keyframes sidebar-nav-item-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-controller {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-controller {
|
||||
animation: sidebar-fade-in 0.4s ease-out forwards;
|
||||
animation-delay: 6.9s;
|
||||
}
|
||||
|
||||
.sidebar-logout-btn {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-logout-btn {
|
||||
animation: sidebar-logout-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation-delay: 7.1s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sidebar-logout-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.sidebar-animate .sidebar-logo {
|
||||
animation: sidebar-logo-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: 6.15s;
|
||||
}
|
||||
|
||||
@keyframes sidebar-logo-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* When not animating, show everything */
|
||||
aside:not(.sidebar-animate) .sidebar-shell {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
aside:not(.sidebar-animate) .sidebar-inner,
|
||||
aside:not(.sidebar-animate) .sidebar-logo,
|
||||
aside:not(.sidebar-animate) .sidebar-nav-item,
|
||||
aside:not(.sidebar-animate) .sidebar-controller,
|
||||
aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
/* Smooth easeInOut - no overshoot, refined deceleration */
|
||||
|
||||
.glass-throw-main {
|
||||
animation: glass-throw-main 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.15s forwards;
|
||||
opacity: 0;
|
||||
transform: translateX(20%) scale(0.2);
|
||||
filter: blur(14px);
|
||||
}
|
||||
|
||||
.glass-throw-content {
|
||||
animation: glass-throw-content 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.22s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(12%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-mobile-tabs {
|
||||
animation: glass-throw-top 1.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.08s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-mobile-tabs-2 {
|
||||
animation: glass-throw-top 1.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.18s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-throw-tabbar {
|
||||
animation: glass-throw-bottom 1.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(85%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Motion blur peaks mid-transition, smooth settle */
|
||||
@keyframes glass-throw-sidebar {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) scale(0.25);
|
||||
filter: blur(12px);
|
||||
}
|
||||
45% {
|
||||
opacity: 0.9;
|
||||
transform: translateX(-15%) scale(0.85);
|
||||
filter: blur(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-main {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20%) scale(0.2);
|
||||
filter: blur(14px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: translateX(0) scale(0.9);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-content {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.9);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-90%) scale(0.28);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.95);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glass-throw-bottom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(85%) scale(0.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(0) scale(0.95);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Oomph accent - subtle 2advanced-style flash synced with boot thud */
|
||||
.oomph-flash {
|
||||
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.08) 0%, transparent 65%);
|
||||
animation: oomph-flash 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes oomph-flash {
|
||||
0% { opacity: 0; }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Reveal flashes - enthralling entrance during zoom */
|
||||
.reveal-flash-glitch {
|
||||
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.12) 0%, transparent 70%);
|
||||
animation: reveal-flash-sequence 2.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes reveal-flash-sequence {
|
||||
0% { opacity: 0; }
|
||||
12% { opacity: 0.6; }
|
||||
18% { opacity: 0; }
|
||||
42% { opacity: 0.4; }
|
||||
48% { opacity: 0; }
|
||||
70% { opacity: 0.35; }
|
||||
78% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
@ -817,10 +1214,10 @@ function getTransitionName(currentRoute: any) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Forward transition: Current screen pulls forward, new screen emerges from back */
|
||||
/* Forward transition: 2advanced fluid depth */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
@ -847,10 +1244,10 @@ function getTransitionName(currentRoute: any) {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back transition: Current screen pulls back, previous screen comes forward */
|
||||
/* Back transition: 2advanced fluid depth */
|
||||
.depth-back-enter-active.view-wrapper,
|
||||
.depth-back-leave-active.view-wrapper {
|
||||
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.depth-back-enter-from.view-wrapper {
|
||||
@ -877,22 +1274,22 @@ function getTransitionName(currentRoute: any) {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Enhanced effect with rotation for more console-like feel */
|
||||
/* Subtle 3D tilt - 2advanced layered depth */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(8deg);
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-5deg);
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
|
||||
}
|
||||
|
||||
.depth-back-enter-from.view-wrapper {
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-5deg);
|
||||
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
|
||||
}
|
||||
|
||||
.depth-back-leave-to.view-wrapper {
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(8deg);
|
||||
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1016,46 +1413,52 @@ function getTransitionName(currentRoute: any) {
|
||||
transform: translateY(30vh);
|
||||
}
|
||||
|
||||
/* Background 3D container */
|
||||
.bg-perspective-container {
|
||||
/* Background 3D container - full width, black fill during zoom to avoid letterboxing */
|
||||
.dashboard-view .bg-perspective-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
perspective: 1000px;
|
||||
perspective-origin: 50% 50%;
|
||||
overflow: hidden;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Background layers with 3D transitions */
|
||||
.bg-layer {
|
||||
/* Background layers with 3D transitions - full width like login */
|
||||
.dashboard-view .bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Default state - bg-4 visible, bg-3 hidden back */
|
||||
.bg-layer:first-child {
|
||||
.dashboard-view .bg-layer:first-of-type {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
}
|
||||
|
||||
.bg-layer:nth-child(2) {
|
||||
.dashboard-view .bg-layer:nth-of-type(2) {
|
||||
opacity: 0;
|
||||
transform: translateZ(-200px) scale(0.9) rotateY(-15deg);
|
||||
}
|
||||
|
||||
/* Transitioning out - current background moves away with zoom */
|
||||
.bg-layer.bg-transitioning-out {
|
||||
.dashboard-view .bg-layer.bg-transitioning-out {
|
||||
opacity: 0;
|
||||
transform: translateZ(200px) scale(1.15) rotateY(15deg) !important;
|
||||
}
|
||||
|
||||
/* Transitioning in - new background comes forward with zoom */
|
||||
.bg-layer.bg-transitioning-in {
|
||||
.dashboard-view .bg-layer.bg-transitioning-in {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1.05) rotateY(0deg) !important;
|
||||
}
|
||||
@ -1166,5 +1569,84 @@ function getTransitionName(currentRoute: any) {
|
||||
95% { opacity: 0.45; }
|
||||
100% { opacity: 0; transform: translateX(120%); }
|
||||
}
|
||||
|
||||
/* Full width background - same as login */
|
||||
.dashboard-view .bg-fullwidth {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
/* Continuous glitch overlays - same as login, every 5s */
|
||||
.dashboard-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dashboard-glitch-1 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(22deg) saturate(1.35);
|
||||
animation: dashboard-glitch-shift 5s steps(10, end) infinite;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
.dashboard-glitch-2 {
|
||||
mix-blend-mode: screen;
|
||||
filter: hue-rotate(-30deg) saturate(1.45);
|
||||
animation: dashboard-glitch-shift-2 5s steps(9, end) infinite;
|
||||
background-size: cover !important;
|
||||
background-position: center center !important;
|
||||
}
|
||||
|
||||
.dashboard-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: dashboard-glitch-scan 5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dashboard-glitch-shift {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.28; }
|
||||
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 dashboard-glitch-shift-2 {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.24; }
|
||||
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 dashboard-glitch-scan {
|
||||
0%, 82% { opacity: 0; transform: translateY(-20%); }
|
||||
84% { opacity: 0.5; }
|
||||
90% { opacity: 0.35; }
|
||||
100% { opacity: 0; transform: translateY(115%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -2,8 +2,14 @@
|
||||
<div>
|
||||
<div class="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Welcome Noderunner</h1>
|
||||
<p class="text-white/80">Here's an overview of your sovereign life</p>
|
||||
<div class="min-h-[4.5rem]">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
|
||||
{{ line1Text }}<span v-if="showCaretLine1" class="typing-caret"></span>
|
||||
</h1>
|
||||
<p class="text-white/80">
|
||||
{{ line2Text }}<span v-if="showCaretLine2" class="typing-caret"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Compact Status Indicator -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 glass-card rounded-lg">
|
||||
@ -15,26 +21,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Overviews -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Section Overviews - 2advanced staggered animation (hidden until typing starts, then animate with typing) -->
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<!-- My Apps Overview -->
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 0"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<RouterLink to="/dashboard/apps" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
@ -44,41 +57,40 @@
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Browse Store
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Apps
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 1"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<RouterLink to="/dashboard/cloud" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||
@ -88,41 +100,40 @@
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
View Folders
|
||||
</RouterLink>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
<button class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" @click="() => {}">
|
||||
Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 2"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<RouterLink to="/dashboard/server" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@ -145,43 +156,42 @@
|
||||
<span class="text-sm text-white/80 font-medium">12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Network
|
||||
</RouterLink>
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
<button class="home-card-btn px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" @click="() => {}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<div
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 3"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<RouterLink to="/dashboard/web5" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@ -204,18 +214,11 @@
|
||||
<span class="text-sm text-orange-500 font-medium">₿0.024</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Web5
|
||||
</RouterLink>
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
<button class="home-card-btn px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" @click="() => {}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
@ -223,6 +226,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions - Hidden for now -->
|
||||
<!--
|
||||
@ -265,12 +270,74 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { PackageState } from '../types/api'
|
||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const LINE1 = "Welcome Noderunner"
|
||||
const LINE2 = "Here's an overview of your sovereign life"
|
||||
const MS_PER_CHAR = 55
|
||||
|
||||
const displayLine1 = ref('')
|
||||
const displayLine2 = ref('')
|
||||
const showCaretLine1 = ref(false)
|
||||
const showCaretLine2 = ref(false)
|
||||
const showWelcomeBlock = ref(false)
|
||||
const hasTypedWelcome = ref(false)
|
||||
const animateCards = ref(false)
|
||||
let typingInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const line1Text = computed(() =>
|
||||
showWelcomeBlock.value ? displayLine1.value : LINE1
|
||||
)
|
||||
const line2Text = computed(() =>
|
||||
showWelcomeBlock.value ? displayLine2.value : LINE2
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typingInterval) clearInterval(typingInterval)
|
||||
})
|
||||
|
||||
watch(() => loginTransition.pendingWelcomeTyping, (pending) => {
|
||||
if (pending) showWelcomeBlock.value = true
|
||||
})
|
||||
|
||||
watch(() => loginTransition.startWelcomeTyping, (shouldStart) => {
|
||||
if (!shouldStart || hasTypedWelcome.value) return
|
||||
hasTypedWelcome.value = true
|
||||
showWelcomeBlock.value = true
|
||||
displayLine1.value = ''
|
||||
displayLine2.value = ''
|
||||
showCaretLine1.value = true
|
||||
showCaretLine2.value = false
|
||||
|
||||
playTypingSound()
|
||||
animateCards.value = true
|
||||
|
||||
let i = 0
|
||||
typingInterval = setInterval(() => {
|
||||
if (i < LINE1.length) {
|
||||
displayLine1.value = LINE1.slice(0, i + 1)
|
||||
i++
|
||||
} else if (i < LINE1.length + LINE2.length) {
|
||||
showCaretLine1.value = false
|
||||
showCaretLine2.value = true
|
||||
displayLine2.value = LINE2.slice(0, i - LINE1.length + 1)
|
||||
i++
|
||||
} else {
|
||||
if (typingInterval) clearInterval(typingInterval)
|
||||
typingInterval = null
|
||||
showCaretLine2.value = false
|
||||
loginTransition.setStartWelcomeTyping(false)
|
||||
}
|
||||
}, MS_PER_CHAR)
|
||||
}, { immediate: true })
|
||||
|
||||
// @ts-ignore - Computed kept for future use
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@ -282,3 +349,136 @@ const runningCount = computed(() =>
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.typing-caret::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1.1em;
|
||||
background: #00ffff;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 0.7s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 2advanced-style card animation sequence */
|
||||
.home-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.home-card-shell {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid transparent;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-card-animate .home-card-shell {
|
||||
animation: card-fly-in 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(var(--card-stagger) * 0.18s);
|
||||
opacity: 0;
|
||||
transform: translateY(50px) scale(0.92);
|
||||
}
|
||||
|
||||
@keyframes card-fly-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) scale(0.92);
|
||||
border-color: transparent;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
border-color: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.home-card-inner {
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.home-card-animate .home-card-inner {
|
||||
animation: inner-draw 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(var(--card-stagger) * 0.18s + 0.9s);
|
||||
}
|
||||
|
||||
@keyframes inner-draw {
|
||||
0% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-card-text {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-card-stats {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-card-btn {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.home-card-animate .home-card-btn {
|
||||
animation: btn-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation-delay: calc(var(--card-stagger) * 0.18s + 1.5s);
|
||||
}
|
||||
|
||||
@keyframes btn-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
border-color: transparent;
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
border-color: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* When not animating, show everything */
|
||||
.home-card:not(.home-card-animate) .home-card-inner,
|
||||
.home-card:not(.home-card-animate) .home-card-btn {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
transform: none;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.home-card:not(.home-card-animate) .home-card-shell {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative z-10">
|
||||
<div class="w-full max-w-md relative z-20">
|
||||
<!-- Login Card -->
|
||||
<div class="glass-card p-8 pt-20 relative login-card overflow-visible">
|
||||
<div
|
||||
class="glass-card p-8 pt-20 relative login-card overflow-visible transition-all duration-300"
|
||||
:class="{ 'whoosh-away': whooshAway }"
|
||||
>
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||
<div class="logo-gradient-border">
|
||||
@ -134,39 +137,46 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { startSynthwave, stopSynthwave, playLoginSuccessWhoosh } from '@/composables/useLoginSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSetup = ref(false)
|
||||
const whooshAway = ref(false)
|
||||
|
||||
// Check if we're in setup mode (original StartOS node setup)
|
||||
const isSetupMode = computed(() => {
|
||||
return import.meta.env.VITE_DEV_MODE === 'setup'
|
||||
})
|
||||
|
||||
// Check if node is already set up
|
||||
onMounted(async () => {
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
} else {
|
||||
sessionStorage.removeItem('archipelago_from_splash')
|
||||
}
|
||||
if (isSetupMode.value) {
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {} })
|
||||
isSetup.value = Boolean(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to check setup status:', err)
|
||||
// Assume not set up if check fails
|
||||
isSetup.value = false
|
||||
}
|
||||
} else {
|
||||
// Not in setup mode, assume already set up
|
||||
isSetup.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = 'Password must be at least 8 characters'
|
||||
@ -187,11 +197,17 @@ async function handleSetup() {
|
||||
params: { password: password.value }
|
||||
})
|
||||
|
||||
// After setup, automatically log in
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await store.login(password.value)
|
||||
router.push('/dashboard')
|
||||
await new Promise(r => setTimeout(r, 350))
|
||||
router.replace({ name: 'home' })
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
error.value = err instanceof Error ? err.message : 'Setup failed. Please try again.'
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -205,9 +221,16 @@ async function handleLogin() {
|
||||
|
||||
try {
|
||||
await store.login(password.value)
|
||||
router.push('/dashboard')
|
||||
stopSynthwave()
|
||||
whooshAway.value = true
|
||||
playLoginSuccessWhoosh()
|
||||
loginTransition.setJustLoggedIn(true)
|
||||
await new Promise(r => setTimeout(r, 350))
|
||||
router.replace({ name: 'home' })
|
||||
} catch (err) {
|
||||
whooshAway.value = false
|
||||
error.value = err instanceof Error ? err.message : 'Login failed. Please check your password.'
|
||||
startSynthwave()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -221,3 +244,11 @@ function replayIntro() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Whoosh accent - login card blurs and shrinks as whoosh plays */
|
||||
.whoosh-away {
|
||||
transform: scale(0.92);
|
||||
filter: blur(6px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -20,7 +20,19 @@
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Static image background for other routes (not using 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"
|
||||
@ -29,9 +41,8 @@
|
||||
:key="currentBackground"
|
||||
></div>
|
||||
|
||||
|
||||
<!-- Glitch overlay layer - only for non-video backgrounds -->
|
||||
<div v-show="isGlitching && !useVideoBackground" class="bg-glitch-layer"></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 -->
|
||||
@ -52,6 +63,7 @@
|
||||
<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-4.jpg')
|
||||
@ -59,9 +71,12 @@ const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// Routes that should use video background (smooth transition from splash)
|
||||
// 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)
|
||||
@ -77,9 +92,11 @@ const routeBackgrounds: Record<string, string> = {
|
||||
'/onboarding/backup': 'bg-7.jpg',
|
||||
'/onboarding/verify': 'bg-2.jpg',
|
||||
'/onboarding/done': 'bg-1.jpg',
|
||||
'/login': 'bg-1.jpg' // Video will be used instead
|
||||
'/login': 'bg-4.jpg' // Video loops from splash (same as intro)
|
||||
}
|
||||
|
||||
const loginBackground = 'bg-1.jpg'
|
||||
|
||||
// Restore video time from splash screen for seamless transition
|
||||
function restoreVideoTime() {
|
||||
if (videoElement.value && useVideoBackground.value) {
|
||||
@ -100,14 +117,7 @@ function restoreVideoTime() {
|
||||
|
||||
// If video was playing, ensure it continues playing immediately
|
||||
if (wasPlaying) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after time restore:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
requestAnimationFrame(() => ensureVideoPlaying())
|
||||
}
|
||||
|
||||
// Clean up session storage after successful restore
|
||||
@ -137,6 +147,18 @@ function restoreVideoTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@ -148,9 +170,7 @@ watch([useVideoBackground, route], ([useVideo]) => {
|
||||
// Then ensure it's playing - use double RAF for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value && videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed:', err)
|
||||
})
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -169,9 +189,7 @@ onMounted(() => {
|
||||
// Use double RAF for smoother playback start
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on mount:', err)
|
||||
})
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -213,9 +231,7 @@ watch(() => route.path, (newPath, oldPath) => {
|
||||
if (oldUsesVideo && newUsesVideo && videoElement.value) {
|
||||
// Video continues seamlessly, just ensure it's playing
|
||||
if (videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed on route change:', err)
|
||||
})
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
// No glitch effect, no zoom, no transitions for video-to-video
|
||||
isGlitching.value = false
|
||||
@ -229,6 +245,14 @@ watch(() => route.path, (newPath, oldPath) => {
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
|
||||
if (newPath === '/login') {
|
||||
currentBackground.value = 'bg-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)
|
||||
@ -260,11 +284,8 @@ watch(() => route.path, (newPath, oldPath) => {
|
||||
// Prevent video from pausing during transitions
|
||||
function handleVideoPause(event: Event) {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
// Prevent pause and immediately resume playback
|
||||
event.preventDefault()
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after pause prevention:', err)
|
||||
})
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,14 +293,12 @@ function handleVideoPause(event: Event) {
|
||||
function handleVideoEnded() {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
videoElement.value.currentTime = 0
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after loop:', err)
|
||||
})
|
||||
ensureVideoPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Update body class to disable global glitch effects ONLY for video backgrounds
|
||||
// This class is ONLY added on /onboarding/intro and /login routes
|
||||
// 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) {
|
||||
@ -299,11 +318,20 @@ onMounted(() => {
|
||||
currentBackground.value = bg
|
||||
}
|
||||
|
||||
// Ensure no transitions or effects on mount for video backgrounds
|
||||
if (useVideoBackground.value) {
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
document.body.classList.add('video-background-active')
|
||||
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
|
||||
startSynthwave()
|
||||
}
|
||||
const unlock = () => {
|
||||
resumeAudioContext()
|
||||
document.removeEventListener('click', unlock)
|
||||
document.removeEventListener('touchstart', unlock)
|
||||
}
|
||||
document.addEventListener('click', unlock, { once: true })
|
||||
document.addEventListener('touchstart', unlock, { once: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -338,16 +366,16 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Forward transition: Current screen pulls forward, new screen emerges from back */
|
||||
/* 2advanced-style: fluid depth transitions */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.7s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-1500px) scale(0.5);
|
||||
filter: blur(8px);
|
||||
transform: translateZ(-1200px) scale(0.6);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.depth-forward-enter-to.view-wrapper {
|
||||
@ -364,13 +392,13 @@ onMounted(() => {
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(600px) scale(1.4);
|
||||
filter: blur(12px);
|
||||
transform: translateZ(500px) scale(1.25);
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Background zoom effect - makes you feel like you're going deeper */
|
||||
/* Background zoom - 2advanced fluid */
|
||||
.bg-zoom {
|
||||
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@ -378,14 +406,14 @@ onMounted(() => {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Enhanced effect with rotation for more console-like feel */
|
||||
/* Subtle 3D tilt - 2advanced layered depth */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-1500px) scale(0.5) rotateX(15deg);
|
||||
transform: translateZ(-1200px) scale(0.6) rotateX(6deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(600px) scale(1.4) rotateX(-10deg);
|
||||
transform: translateZ(500px) scale(1.25) rotateX(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,6 +425,16 @@ onMounted(() => {
|
||||
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 {
|
||||
@ -425,6 +463,79 @@ video.bg-layer {
|
||||
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;
|
||||
|
||||
77
scripts/setup-kiosk.sh
Normal file
77
scripts/setup-kiosk.sh
Normal file
@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup Archipelago kiosk mode on the server
|
||||
# Runs Chromium in kiosk mode so keyboard/touchpad control the web UI
|
||||
# Only starts when logging in at the physical console (tty1)
|
||||
#
|
||||
# Run on server: sudo ./setup-kiosk.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
KIOSK_USER="${1:-archipelago}"
|
||||
ARCHIPELAGO_URL="${ARCHIPELAGO_URL:-http://localhost}"
|
||||
|
||||
echo "Setting up kiosk for user: $KIOSK_USER"
|
||||
echo "URL: $ARCHIPELAGO_URL"
|
||||
echo ""
|
||||
|
||||
# Create .xinitrc for kiosk
|
||||
HOMEDIR=$(getent passwd "$KIOSK_USER" | cut -d: -f6)
|
||||
XINITRC="$HOMEDIR/.xinitrc"
|
||||
|
||||
cat > "$XINITRC" << 'XINITRC_EOF'
|
||||
#!/bin/bash
|
||||
# Archipelago kiosk - Chromium fullscreen
|
||||
exec chromium --kiosk \
|
||||
--app=http://localhost \
|
||||
--noerrdialogs \
|
||||
--disable-infobars \
|
||||
--disable-translate \
|
||||
--no-first-run \
|
||||
--check-for-update-interval=31536000 \
|
||||
--disable-features=TranslateUI \
|
||||
--disable-session-crashed-bubble
|
||||
XINITRC_EOF
|
||||
|
||||
# Replace localhost with actual URL if different
|
||||
if [ "$ARCHIPELAGO_URL" != "http://localhost" ]; then
|
||||
sed -i "s|http://localhost|$ARCHIPELAGO_URL|g" "$XINITRC"
|
||||
fi
|
||||
|
||||
chown "$KIOSK_USER:$KIOSK_USER" "$XINITRC"
|
||||
chmod +x "$XINITRC"
|
||||
|
||||
# Add startx to .bash_profile only when on console (tty1)
|
||||
BASHPROFILE="$HOMEDIR/.bash_profile"
|
||||
if [ ! -f "$BASHPROFILE" ]; then
|
||||
touch "$BASHPROFILE"
|
||||
chown "$KIOSK_USER:$KIOSK_USER" "$BASHPROFILE"
|
||||
fi
|
||||
|
||||
# Remove any existing kiosk block
|
||||
if grep -q "ARCHIPELAGO_KIOSK" "$BASHPROFILE" 2>/dev/null; then
|
||||
sed -i '/# ARCHIPELAGO_KIOSK/,/^# END ARCHIPELAGO_KIOSK/d' "$BASHPROFILE"
|
||||
fi
|
||||
|
||||
# Add kiosk startup (only runs on physical console tty1)
|
||||
cat >> "$BASHPROFILE" << 'BASHPROFILE_EOF'
|
||||
|
||||
# ARCHIPELAGO_KIOSK - Start X/kiosk when logging in at physical console
|
||||
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
||||
exec startx
|
||||
fi
|
||||
# END ARCHIPELAGO_KIOSK
|
||||
BASHPROFILE_EOF
|
||||
|
||||
chown "$KIOSK_USER:$KIOSK_USER" "$BASHPROFILE"
|
||||
|
||||
echo "✅ Kiosk installed!"
|
||||
echo ""
|
||||
echo " When you log in at the physical console (monitor + keyboard):"
|
||||
echo " - X will start automatically"
|
||||
echo " - Chromium will open in kiosk mode"
|
||||
echo " - Your keyboard/touchpad will control the Archipelago UI"
|
||||
echo ""
|
||||
echo " To use: Connect a display, plug in keyboard, reboot (or log in at tty1)"
|
||||
echo ""
|
||||
Loading…
x
Reference in New Issue
Block a user