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:
Dorian 2026-02-17 19:19:54 +00:00
parent 1073d9fd2c
commit 1b05b5b8f1
24 changed files with 2038 additions and 470 deletions

BIN
intro-typing.mp3 Normal file

Binary file not shown.

BIN
loop-start.mp3 Normal file

Binary file not shown.

View File

@ -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"), {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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') {

View File

@ -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,101 +279,127 @@ 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)
typeLine(3, displayLine4, isTypingLine4, () => {
isTypingLine4.value = false
fadeAlienIntro.value = true
introTypingTimeout = setTimeout(showWelcomePhase, 800)
})
}
// Fade out alien intro - wait for line 4 typing (4s) + blinking (1.5s)
setTimeout(() => {
typingLine4.value = false
fadeAlienIntro.value = true
}, 22500)
// 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(() => {
fadeWelcome.value = true
typingWelcome.value = false
}, 28150)
introTypingTimeout = setTimeout(() => {
fadeWelcome.value = true
typingWelcome.value = false
}, 4850)
// Show logo - background stays at 0.3 opacity
setTimeout(() => {
showLogo.value = true
}, 29000)
introTypingTimeout = setTimeout(() => {
showLogo.value = true
}, 5500)
// Hide welcome after logo starts appearing
setTimeout(() => {
showWelcome.value = false
}, 30500)
// Fade background to full opacity just before completing (for smooth transition to modal)
setTimeout(() => {
backgroundOpacity.value = 1
}, 33000)
introTypingTimeout = setTimeout(() => {
showWelcome.value = false
}, 6000)
introTypingTimeout = setTimeout(() => {
backgroundOpacity.value = 1
}, 9000)
introTypingTimeout = setTimeout(() => {
if (videoElement.value && !videoElement.value.paused) {
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
sessionStorage.setItem('video_intro_wasPlaying', 'true')
}
showSplash.value = false
document.body.classList.add('splash-complete')
localStorage.setItem('neode_intro_seen', '1')
emit('complete')
}, 9500)
}
introTypingTimeout = setTimeout(scheduleLine1, 500)
// Complete splash with smooth transition
setTimeout(() => {
// Store final video time right before unmounting
if (videoElement.value && !videoElement.value.paused) {
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
sessionStorage.setItem('video_intro_wasPlaying', 'true')
}
showSplash.value = false
document.body.classList.add('splash-complete')
localStorage.setItem('neode_intro_seen', '1')
emit('complete')
}, 34500)
}
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;

View File

@ -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()
}
}

View 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)
}
}

View File

@ -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
}

View 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
}
}
}

View File

@ -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

View 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,
}
})

View File

@ -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,

View File

@ -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 -->
<div
<!-- 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 }"
:style="{ backgroundImage: `url(/assets/img/${currentBackgroundImage})` }"
/>
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,61 +66,70 @@
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">
<AnimatedLogo />
<div class="min-w-0 flex-1">
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
<p class="text-xs text-white/60">v{{ version }}</p>
<!-- 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>
<p class="text-xs text-white/60">v{{ version }}</p>
</div>
</div>
<nav class="sidebar-nav space-y-2 p-6 pt-4">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
:to="item.path"
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
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<div class="sidebar-controller px-6 pb-2">
<ControllerIndicator />
</div>
<div class="sidebar-logout p-6">
<button
@click="handleLogout"
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" />
</svg>
<span>Logout</span>
</button>
</div>
</div>
<nav class="space-y-2">
<RouterLink
v-for="item 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"
exact-active-class="nav-tab-active"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</div>
<!-- Controller indicator - Desktop sidebar -->
<div class="px-6 pb-2">
<ControllerIndicator />
</div>
<!-- User Section - Desktop Only -->
<div class="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"
>
<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" />
</svg>
<span>Logout</span>
</button>
</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,11 +545,35 @@ 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()
window.addEventListener('resize', () => {
updateTabBarHeight()
updateAppsTabIndicator()
@ -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>

View File

@ -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,212 +21,211 @@
</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>
<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
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">
<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="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>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Running</p>
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
</div>
</div>
<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="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>
<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="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>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Running</p>
<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"
>
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"
>
Manage Apps
</RouterLink>
</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>
<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
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">
<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="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>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Folders</p>
<p class="text-2xl font-bold text-white">5</p>
</div>
</div>
<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="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>
<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="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>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Folders</p>
<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"
>
View Folders
</RouterLink>
<button
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
@click="() => {}"
>
Upload Files
</button>
</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>
<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"
>
<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="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>
<span class="text-sm text-white/80">Services Status</span>
<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">
<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>
<span class="text-sm text-green-400 font-medium">All Running</span>
</div>
<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>
<span class="text-sm text-white/80">Connectivity</span>
<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>
<span class="text-sm text-white/80">Services Status</span>
</div>
<span class="text-sm text-green-400 font-medium">All Running</span>
</div>
<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>
<span class="text-sm text-white/80">Connectivity</span>
</div>
<span class="text-sm text-green-400 font-medium">Connected</span>
</div>
<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-blue-400"></div>
<span class="text-sm text-white/80">Connected Nodes</span>
</div>
<span class="text-sm text-white/80 font-medium">12</span>
</div>
</div>
<span class="text-sm text-green-400 font-medium">Connected</span>
</div>
<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-blue-400"></div>
<span class="text-sm text-white/80">Connected Nodes</span>
<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="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>
<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"
>
Manage Network
</RouterLink>
<button
class="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>
<!-- 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>
<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"
>
<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="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>
<span class="text-sm text-white/80">DID Status</span>
<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">
<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>
<span class="text-sm text-green-400 font-medium">Active</span>
</div>
<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>
<span class="text-sm text-white/80">DWN Sync</span>
<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>
<span class="text-sm text-white/80">DID Status</span>
</div>
<span class="text-sm text-green-400 font-medium">Active</span>
</div>
<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>
<span class="text-sm text-white/80">DWN Sync</span>
</div>
<span class="text-sm text-green-400 font-medium">Synced</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold"></span>
<span class="text-sm text-white/80">Networking Profits</span>
</div>
<span class="text-sm text-orange-500 font-medium">0.024</span>
</div>
</div>
<span class="text-sm text-green-400 font-medium">Synced</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg text-orange-500 font-bold"></span>
<span class="text-sm text-white/80">Networking Profits</span>
<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="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>
<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"
>
Manage Web5
</RouterLink>
<button
class="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>
@ -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>

View File

@ -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>

View File

@ -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
View 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 ""