Update UI components and enhance controller navigation for improved user experience
- Updated styles in various components to change color themes from cyan to yellow for better visual consistency. - Enhanced focus management in controller navigation to improve accessibility and user interaction. - Added new data attributes for controller navigation in multiple views to streamline user interactions with app containers. - Improved audio handling by removing unused functions in useLoginSounds.ts, optimizing the codebase.
This commit is contained in:
parent
5a04875dcc
commit
316dfee2fc
@ -9,6 +9,12 @@
|
|||||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||||
<SpotlightSearch />
|
<SpotlightSearch />
|
||||||
|
|
||||||
|
<!-- App launcher overlay (iframe popup) -->
|
||||||
|
<AppLauncherOverlay />
|
||||||
|
|
||||||
|
<!-- Screensaver -->
|
||||||
|
<Screensaver />
|
||||||
|
|
||||||
<!-- Help guide modal (from spotlight) -->
|
<!-- Help guide modal (from spotlight) -->
|
||||||
<HelpGuideModal
|
<HelpGuideModal
|
||||||
:show="spotlightStore.helpModal.show"
|
:show="spotlightStore.helpModal.show"
|
||||||
@ -53,13 +59,17 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import SplashScreen from './components/SplashScreen.vue'
|
import SplashScreen from './components/SplashScreen.vue'
|
||||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||||
import SpotlightSearch from './components/SpotlightSearch.vue'
|
import SpotlightSearch from './components/SpotlightSearch.vue'
|
||||||
|
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
|
||||||
|
import Screensaver from './components/Screensaver.vue'
|
||||||
import HelpGuideModal from './components/HelpGuideModal.vue'
|
import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||||
import { useControllerNav } from '@/composables/useControllerNav'
|
import { useControllerNav } from '@/composables/useControllerNav'
|
||||||
import { useSpotlightStore } from '@/stores/spotlight'
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
import { useMessageToast } from '@/composables/useMessageToast'
|
import { useMessageToast } from '@/composables/useMessageToast'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useScreensaverStore } from '@/stores/screensaver'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const screensaverStore = useScreensaverStore()
|
||||||
const spotlightStore = useSpotlightStore()
|
const spotlightStore = useSpotlightStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const messageToast = useMessageToast()
|
const messageToast = useMessageToast()
|
||||||
@ -71,12 +81,22 @@ useControllerNav()
|
|||||||
watch(() => appStore.isAuthenticated, (authenticated) => {
|
watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
messageToast.startPolling()
|
messageToast.startPolling()
|
||||||
|
screensaverStore.resetInactivityTimer()
|
||||||
} else {
|
} else {
|
||||||
messageToast.stopPolling()
|
messageToast.stopPolling()
|
||||||
toastMessage.value = { show: false, text: '' }
|
toastMessage.value = { show: false, text: '' }
|
||||||
|
screensaverStore.clearInactivityTimer()
|
||||||
|
screensaverStore.deactivate()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Reset screensaver inactivity on user activity (when authenticated)
|
||||||
|
function onUserActivity() {
|
||||||
|
if (appStore.isAuthenticated && !screensaverStore.isActive) {
|
||||||
|
screensaverStore.resetInactivityTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
const mod = isMac ? e.metaKey : e.ctrlKey
|
||||||
@ -98,6 +118,10 @@ const isReady = ref(false)
|
|||||||
*/
|
*/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('mousemove', onUserActivity)
|
||||||
|
window.addEventListener('mousedown', onUserActivity)
|
||||||
|
window.addEventListener('keydown', onUserActivity)
|
||||||
|
window.addEventListener('touchstart', onUserActivity)
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const isDirectRoute = route.path !== '/'
|
const isDirectRoute = route.path !== '/'
|
||||||
|
|
||||||
@ -114,6 +138,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('mousemove', onUserActivity)
|
||||||
|
window.removeEventListener('mousedown', onUserActivity)
|
||||||
|
window.removeEventListener('keydown', onUserActivity)
|
||||||
|
window.removeEventListener('touchstart', onUserActivity)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,20 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="logo-gradient-border flex-shrink-0 inline-block overflow-hidden">
|
<div class="logo-gradient-border flex-shrink-0 inline-block overflow-hidden" :class="sizeClass">
|
||||||
<!-- Neode logo - white or coloured -->
|
<!-- Neode logo - always white -->
|
||||||
<svg
|
<svg
|
||||||
class="w-14 h-14 block logo-svg"
|
class="block logo-svg"
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-label="Neode"
|
aria-label="Neode"
|
||||||
>
|
>
|
||||||
<defs>
|
|
||||||
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#f9aa4b" />
|
|
||||||
<stop offset="50%" stop-color="#f7931a" />
|
|
||||||
<stop offset="100%" stop-color="#e68a19" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="1024" height="1024" fill="#030202" />
|
<rect width="1024" height="1024" fill="#030202" />
|
||||||
<rect
|
<rect
|
||||||
v-for="(r, i) in rects"
|
v-for="(r, i) in rects"
|
||||||
@ -23,9 +16,8 @@
|
|||||||
:y="r.y"
|
:y="r.y"
|
||||||
:width="r.w"
|
:width="r.w"
|
||||||
:height="r.h"
|
:height="r.h"
|
||||||
:fill="mode === 'coloured' ? `url(#${gradientId})` : 'white'"
|
fill="white"
|
||||||
class="logo-square"
|
class="logo-square"
|
||||||
:class="{ 'logo-square-coloured': mode === 'coloured' }"
|
|
||||||
:style="{ '--delay': delays[i] + 'ms' }"
|
:style="{ '--delay': delays[i] + 'ms' }"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -33,9 +25,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
const props = withDefaults(defineProps<{
|
||||||
|
size?: 'sm' | 'lg' | 'xl'
|
||||||
|
}>(), { size: 'sm' })
|
||||||
|
|
||||||
const gradientId = 'logo-color-' + Math.random().toString(36).slice(2, 11)
|
const sizeClass = props.size === 'xl' ? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80' : props.size === 'lg' ? 'w-32 h-32 sm:w-48 sm:h-48' : 'w-14 h-14'
|
||||||
|
|
||||||
// Parsed from favico-black.svg path - 20 rects
|
// Parsed from favico-black.svg path - 20 rects
|
||||||
const rects = [
|
const rects = [
|
||||||
@ -61,26 +55,9 @@ const rects = [
|
|||||||
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
|
{ x: 595.379, y: 633.989, w: 71.007, h: 72.011 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Stagger delays (ms) - spread over ~4.5s load phase
|
// Stagger delays (ms) - row-by-row top-to-bottom, left-to-right for a clean reveal
|
||||||
const delays = [0, 2341, 467, 1890, 312, 3456, 123, 2789, 567, 4123, 901, 1456, 234, 3789, 2678, 456, 847, 2912, 1891, 423]
|
// Row 1 (y~318): 0,1,2,3 | Row 2 (y~396): 4,5 | Row 3 (y~476): 6-11 | Row 4 (y~555): 12-15 | Row 5 (y~634): 16-19
|
||||||
|
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
|
||||||
type Mode = 'normal' | 'coloured'
|
|
||||||
const mode = ref<Mode>('normal')
|
|
||||||
let cycleCount = 0
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const CYCLE_MS = 10000
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
cycleCount++
|
|
||||||
mode.value = cycleCount % 4 === 3 ? 'coloured' : 'normal'
|
|
||||||
}, CYCLE_MS)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (intervalId) clearInterval(intervalId)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -90,20 +67,15 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.logo-square {
|
.logo-square {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: logo-square-in 10s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
animation: logo-square-in 3s ease-out infinite;
|
||||||
animation-delay: var(--delay, 0ms);
|
animation-delay: var(--delay, 0ms);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-square-coloured {
|
/* Fade in only - no scale/position change. Squares stay fixed. */
|
||||||
filter: drop-shadow(0 0 2px rgba(247, 147, 26, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 0–45%: squares load in. 45–100%: full logo visible */
|
|
||||||
@keyframes logo-square-in {
|
@keyframes logo-square-in {
|
||||||
0% { opacity: 0; transform: scale(0.95); }
|
0% { opacity: 0; }
|
||||||
4% { opacity: 1; transform: scale(1); }
|
15% { opacity: 1; }
|
||||||
45% { opacity: 1; transform: scale(1); }
|
100% { opacity: 1; }
|
||||||
100% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
111
neode-ui/src/components/AppLauncherOverlay.vue
Normal file
111
neode-ui/src/components/AppLauncherOverlay.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="app-launcher">
|
||||||
|
<div
|
||||||
|
v-if="store.isOpen"
|
||||||
|
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
|
||||||
|
@click.self="store.close()"
|
||||||
|
>
|
||||||
|
<!-- Backdrop - blur like spotlight -->
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
|
|
||||||
|
<!-- Panel - inset with margins, glass style like spotlight -->
|
||||||
|
<div
|
||||||
|
class="app-launcher-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl"
|
||||||
|
:class="panelClasses"
|
||||||
|
>
|
||||||
|
<!-- Header bar - drag handle + title + close -->
|
||||||
|
<div class="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 shrink-0 rounded cursor-grab hover:bg-white/10 transition-colors">
|
||||||
|
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ store.title || 'App' }}</span>
|
||||||
|
<button
|
||||||
|
ref="closeBtnRef"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="store.close()"
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Iframe container -->
|
||||||
|
<div class="relative flex-1 min-h-0 bg-black/40">
|
||||||
|
<iframe
|
||||||
|
v-if="store.url"
|
||||||
|
:src="store.url"
|
||||||
|
class="absolute inset-0 w-full h-full border-0"
|
||||||
|
title="App content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
|
||||||
|
const store = useAppLauncherStore()
|
||||||
|
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const panelClasses = [
|
||||||
|
'glass-card',
|
||||||
|
'w-full h-full max-w-[calc(100vw-3rem)] max-h-[calc(100vh-5rem)]',
|
||||||
|
'md:max-w-[calc(100vw-5rem)] md:max-h-[calc(100vh-5rem)]',
|
||||||
|
]
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && store.isOpen) {
|
||||||
|
store.close()
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
closeBtnRef.value?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-launcher-enter-active,
|
||||||
|
.app-launcher-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.app-launcher-enter-active .app-launcher-panel,
|
||||||
|
.app-launcher-leave-active .app-launcher-panel {
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.app-launcher-enter-from,
|
||||||
|
.app-launcher-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.app-launcher-enter-from .app-launcher-panel,
|
||||||
|
.app-launcher-leave-to .app-launcher-panel {
|
||||||
|
transform: scale(0.96);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
neode-ui/src/components/Screensaver.vue
Normal file
166
neode-ui/src/components/Screensaver.vue
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="screensaver">
|
||||||
|
<div
|
||||||
|
v-if="store.isActive"
|
||||||
|
class="screensaver-container fixed inset-0 z-[3000] bg-black cursor-pointer"
|
||||||
|
@click="store.deactivate()"
|
||||||
|
@keydown.escape="store.deactivate()"
|
||||||
|
>
|
||||||
|
<!-- Logo with audio viz ring - explicitly centered in viewport -->
|
||||||
|
<div class="screensaver-content">
|
||||||
|
<!-- Radial audio visualization - bars around the logo -->
|
||||||
|
<div class="screensaver-viz-ring">
|
||||||
|
<div
|
||||||
|
v-for="(_, i) in segmentCount"
|
||||||
|
:key="i"
|
||||||
|
class="screensaver-viz-segment"
|
||||||
|
:style="getSegmentStyle(i)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Logo in center -->
|
||||||
|
<div class="screensaver-logo-wrapper relative z-10">
|
||||||
|
<AnimatedLogo size="xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
import { useScreensaverStore } from '@/stores/screensaver'
|
||||||
|
|
||||||
|
const store = useScreensaverStore()
|
||||||
|
|
||||||
|
const segmentCount = 48
|
||||||
|
|
||||||
|
function getSegmentStyle(i: number) {
|
||||||
|
const deg = (i / segmentCount) * 360
|
||||||
|
return {
|
||||||
|
'--segment-index': i,
|
||||||
|
'--segment-deg': `${deg}deg`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss on any key (except when typing)
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (store.isActive) {
|
||||||
|
store.deactivate()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.screensaver-enter-active,
|
||||||
|
.screensaver-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.screensaver-enter-from,
|
||||||
|
.screensaver-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Explicit viewport centering */
|
||||||
|
.screensaver-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screensaver-content {
|
||||||
|
position: relative;
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.screensaver-content {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.screensaver-content {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ring of segments around the logo - audio viz style */
|
||||||
|
.screensaver-viz-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
--viz-radius: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.screensaver-viz-ring {
|
||||||
|
--viz-radius: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.screensaver-viz-ring {
|
||||||
|
--viz-radius: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screensaver-viz-segment {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
margin-left: -2px;
|
||||||
|
margin-top: -12px;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 2px;
|
||||||
|
/* Origin at segment center = ring center (segment is centered via left/top 50%) */
|
||||||
|
transform-origin: center center;
|
||||||
|
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius)));
|
||||||
|
animation: segment-pulse 2s ease-in-out infinite;
|
||||||
|
animation-delay: calc(var(--segment-index) * 0.02s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.screensaver-viz-segment {
|
||||||
|
height: 28px;
|
||||||
|
margin-top: -14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes segment-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screensaver-logo-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -10,6 +10,7 @@ import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useControllerStore } from '@/stores/controller'
|
import { useControllerStore } from '@/stores/controller'
|
||||||
import { useSpotlightStore } from '@/stores/spotlight'
|
import { useSpotlightStore } from '@/stores/spotlight'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { playNavSound } from '@/composables/useNavSounds'
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = [
|
const FOCUSABLE_SELECTOR = [
|
||||||
@ -153,6 +154,12 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
|
|
||||||
// --- ESCAPE ---
|
// --- ESCAPE ---
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
if (useAppLauncherStore().isOpen) {
|
||||||
|
useAppLauncherStore().close()
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (useSpotlightStore().isOpen) {
|
if (useSpotlightStore().isOpen) {
|
||||||
useSpotlightStore().close()
|
useSpotlightStore().close()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
28
neode-ui/src/stores/appLauncher.ts
Normal file
28
neode-ui/src/stores/appLauncher.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const url = ref('')
|
||||||
|
const title = ref('')
|
||||||
|
|
||||||
|
function open(payload: { url: string; title: string }) {
|
||||||
|
url.value = payload.url
|
||||||
|
title.value = payload.title
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false
|
||||||
|
url.value = ''
|
||||||
|
title.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
})
|
||||||
42
neode-ui/src/stores/screensaver.ts
Normal file
42
neode-ui/src/stores/screensaver.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
|
||||||
|
|
||||||
|
export const useScreensaverStore = defineStore('screensaver', () => {
|
||||||
|
const isActive = ref(false)
|
||||||
|
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
isActive.value = true
|
||||||
|
clearInactivityTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate() {
|
||||||
|
isActive.value = false
|
||||||
|
resetInactivityTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInactivityTimer() {
|
||||||
|
clearInactivityTimer()
|
||||||
|
inactivityTimer = setTimeout(() => {
|
||||||
|
inactivityTimer = null
|
||||||
|
isActive.value = true
|
||||||
|
}, INACTIVITY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInactivityTimer() {
|
||||||
|
if (inactivityTimer) {
|
||||||
|
clearTimeout(inactivityTimer)
|
||||||
|
inactivityTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive,
|
||||||
|
activate,
|
||||||
|
deactivate,
|
||||||
|
resetInactivityTimer,
|
||||||
|
clearInactivityTimer,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -429,6 +429,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||||
import { dummyApps } from '../utils/dummyApps'
|
import { dummyApps } from '../utils/dummyApps'
|
||||||
@ -663,7 +664,7 @@ function launchApp() {
|
|||||||
|
|
||||||
if (appUrls[id]) {
|
if (appUrls[id]) {
|
||||||
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -171,6 +171,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -223,7 +224,7 @@ function launchApp(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lanAddress) {
|
if (lanAddress) {
|
||||||
window.open(lanAddress, '_blank', 'noopener,noreferrer')
|
useAppLauncherStore().open({ url: lanAddress, title: pkg?.manifest?.title || id })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +247,7 @@ function launchApp(id: string) {
|
|||||||
const currentHost = window.location.hostname
|
const currentHost = window.location.hostname
|
||||||
url = url.replace('localhost', currentHost)
|
url = url.replace('localhost', currentHost)
|
||||||
}
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
useAppLauncherStore().open({ url, title: pkg?.manifest?.title || id })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -120,6 +121,8 @@ function openFolder(folderId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNextcloud() {
|
function openNextcloud() {
|
||||||
window.open('http://localhost:8086', '_blank', 'noopener,noreferrer')
|
const host = window.location.hostname
|
||||||
|
const url = `http://${host}:8086`
|
||||||
|
useAppLauncherStore().open({ url, title: 'Nextcloud' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -132,16 +132,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ store.isAppLoading(app.id) ? 'Stopping...' : 'Stop' }}</span>
|
<span>{{ store.isAppLoading(app.id) ? 'Stopping...' : 'Stop' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<button
|
||||||
:href="getLaunchUrl(app)"
|
type="button"
|
||||||
target="_blank"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
|
||||||
|
@click="launchApp(app)"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</svg>
|
</svg>
|
||||||
Launch
|
Launch
|
||||||
</a>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -201,9 +201,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, computed } from 'vue'
|
import { onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useContainerStore, type BundledApp } from '@/stores/container'
|
import { useContainerStore, type BundledApp } from '@/stores/container'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||||
|
|
||||||
const store = useContainerStore()
|
const store = useContainerStore()
|
||||||
|
const appLauncherStore = useAppLauncherStore()
|
||||||
|
|
||||||
// Use enriched bundled apps with runtime data (like lan_address)
|
// Use enriched bundled apps with runtime data (like lan_address)
|
||||||
const bundledApps = computed(() => store.enrichedBundledApps)
|
const bundledApps = computed(() => store.enrichedBundledApps)
|
||||||
@ -331,6 +333,12 @@ function getLaunchUrl(app: BundledApp): string {
|
|||||||
return `http://${currentHost.value}:${port}`
|
return `http://${currentHost.value}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function launchApp(app: BundledApp) {
|
||||||
|
const url = getLaunchUrl(app)
|
||||||
|
if (url === '#') return
|
||||||
|
appLauncherStore.open({ url, title: app.name })
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartApp(app: BundledApp) {
|
async function handleStartApp(app: BundledApp) {
|
||||||
try {
|
try {
|
||||||
await store.startBundledApp(app)
|
await store.startBundledApp(app)
|
||||||
|
|||||||
@ -11,14 +11,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Compact Status Indicator -->
|
<!-- Compact Status Indicator - click to trigger screensaver (dev) -->
|
||||||
<div class="flex items-center gap-2 px-4 py-2 glass-card rounded-lg">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 glass-card rounded-lg cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
|
@click="screensaverStore.activate()"
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-white">Online</span>
|
<span class="text-sm font-medium text-white">Online</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Overviews - 2advanced staggered animation (hidden until typing starts, then animate with typing) -->
|
<!-- Section Overviews - 2advanced staggered animation (hidden until typing starts, then animate with typing) -->
|
||||||
@ -273,11 +277,13 @@
|
|||||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
import { useScreensaverStore } from '../stores/screensaver'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
|
const screensaverStore = useScreensaverStore()
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
|
|
||||||
const LINE1 = "Welcome Noderunner"
|
const LINE1 = "Welcome Noderunner"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user