feat: gamepad navigation rewrite, focus styling, container grid system
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 34m52s
All checks were successful
Build Archipelago ISO / build-iso (push) Successful in 34m52s
- Rewrite useControllerNav.ts with clean console-style navigation: Sidebar (up/down wrap, right→containers, left→nothing), Container tile grid (spatial nav, no wrap at edges), Nav bar support (up from containers, down to grid), Inner controls (enter drills in, escape exits, trapped arrows) - Add data-controller-container to Mesh, Fleet, Settings pages - Fix Home.vue fragment (modals outside root div) causing Vue warnings - Remove skip-to-content link (handled by controller nav) - Orange ambient glow focus styling matching glass aesthetic - Disable PWA service worker in dev mode (fixes HMR caching) - Add gamepad-nav skill and GAMEPAD-NAV-MAP.md spec document - 39 tests covering all navigation patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,28 @@
|
||||
/**
|
||||
* 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
|
||||
* Controller / gamepad navigation for Archipelago.
|
||||
*
|
||||
* Navigation model (from the design spec):
|
||||
*
|
||||
* SIDEBAR (vertical list):
|
||||
* Up/Down = move between items, wraps top↔bottom, auto-navigates
|
||||
* Right = jump to first container in main content
|
||||
* Left = does nothing
|
||||
*
|
||||
* MAIN CONTENT (container tile grid):
|
||||
* Arrows = move between containers spatially (the red tile grid)
|
||||
* Enter = trigger container's primary action (navigate link / launch)
|
||||
* Escape = back to sidebar
|
||||
* Left from leftmost container = back to sidebar
|
||||
*
|
||||
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
|
||||
* Arrows = move between inner controls spatially
|
||||
* Escape = exit back to the container tile
|
||||
* Cannot move to other containers without exiting first
|
||||
*
|
||||
* TEXT INPUTS:
|
||||
* Up/Down = exit field, navigate to nearest element
|
||||
* Enter = submit (click next button)
|
||||
* Left/Right = cursor movement (stay in field)
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
@@ -14,6 +33,8 @@ import { useCLIStore } from '@/stores/cli'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
// ─── Element Queries ────────────────────────────────────────────
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
@@ -25,9 +46,9 @@ const FOCUSABLE_SELECTOR = [
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) =>
|
||||
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
el =>
|
||||
!el.hasAttribute('disabled') &&
|
||||
el.offsetParent !== null &&
|
||||
!el.hasAttribute('data-controller-ignore') &&
|
||||
@@ -35,10 +56,44 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
|
||||
)
|
||||
}
|
||||
|
||||
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
||||
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
||||
if (!container) return []
|
||||
return getFocusableElements(container)
|
||||
/** Sidebar items */
|
||||
function getSidebarElements(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
|
||||
return zone ? getFocusableElements(zone) : []
|
||||
}
|
||||
|
||||
/** Main zone containers only — the [C] tile grid */
|
||||
function getContainers(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (!zone) return []
|
||||
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
|
||||
el => el.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
/** Nav bar items [N] — focusable elements in main zone that are NOT inside any container
|
||||
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
|
||||
function getNavBarItems(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (!zone) return []
|
||||
return getFocusableElements(zone).filter(el =>
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
}
|
||||
|
||||
function isNavBarItem(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
return isInZone(el, 'main') &&
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
}
|
||||
|
||||
/** Inner focusables within a container (buttons, links — not the container itself) */
|
||||
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return getFocusableElements(container).filter(
|
||||
el => el !== container && !el.hasAttribute('data-controller-container')
|
||||
)
|
||||
}
|
||||
|
||||
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
@@ -46,87 +101,92 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
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 isContainer(el: HTMLElement | null): boolean {
|
||||
return !!el?.hasAttribute('data-controller-container')
|
||||
}
|
||||
|
||||
// ─── Spatial Navigation ─────────────────────────────────────────
|
||||
|
||||
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 fromCX = fromRect.left + fromRect.width / 2
|
||||
const fromCY = fromRect.top + fromRect.height / 2
|
||||
const threshold = 50
|
||||
|
||||
const filtered = candidates.filter((el) => {
|
||||
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
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
if (!filtered.length) return null
|
||||
|
||||
// Pick best: most overlap on perpendicular axis, then closest
|
||||
const scored = filtered.map((el) => {
|
||||
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
|
||||
}
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
const isVertical = direction === 'up' || direction === 'down'
|
||||
const overlap = isVertical
|
||||
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
|
||||
return { el, overlap, dist }
|
||||
})
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
if (a.dist !== b.dist) return a.dist - b.dist
|
||||
// Tiebreaker for up/down: prefer leftmost element in grid layouts
|
||||
// Tiebreaker: prefer leftmost for up/down
|
||||
if (direction === 'up' || direction === 'down') {
|
||||
const aLeft = a.el.getBoundingClientRect().left
|
||||
const bLeft = b.el.getBoundingClientRect().left
|
||||
return aLeft - bLeft
|
||||
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return scored[0]?.el ?? null
|
||||
}
|
||||
|
||||
// ─── Focus Memory ───────────────────────────────────────────────
|
||||
|
||||
const zoneFocusMemory = new Map<string, HTMLElement>()
|
||||
|
||||
function rememberFocus(zone: string, el: HTMLElement) {
|
||||
zoneFocusMemory.set(zone, el)
|
||||
}
|
||||
|
||||
function recallFocus(zone: string): HTMLElement | null {
|
||||
const el = zoneFocusMemory.get(zone)
|
||||
if (!el) return null
|
||||
if (document.contains(el) && el.offsetParent !== null) return el
|
||||
zoneFocusMemory.delete(zone)
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Focus Helper ───────────────────────────────────────────────
|
||||
|
||||
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
|
||||
playNavSound(sound)
|
||||
el.focus({ preventScroll: true })
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// ─── Main Composable ────────────────────────────────────────────
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -138,110 +198,89 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
store.setActive(isControllerActive.value)
|
||||
store.setGamepadCount(gamepadCount.value)
|
||||
}, { immediate: true })
|
||||
|
||||
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function checkGamepads() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
|
||||
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
|
||||
if (count !== gamepadCount.value) {
|
||||
gamepadCount.value = count
|
||||
isControllerActive.value = count > 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keyboard Handler ───────────────────────────────────────
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
if (!navKeys.includes(e.key)) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
// Enter in text field: find next focusable — if it's a button, click it directly (submit)
|
||||
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
|
||||
// Enter in input: click next button (submit pattern)
|
||||
e.preventDefault()
|
||||
const root = containerRef?.value ?? document
|
||||
const all = getFocusableElements(root)
|
||||
const idx = all.indexOf(target as HTMLElement)
|
||||
const all = getFocusableElements(containerRef?.value ?? document)
|
||||
const idx = all.indexOf(target)
|
||||
const next = idx >= 0 ? all[idx + 1] : undefined
|
||||
if (next) {
|
||||
if (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button') {
|
||||
next.focus()
|
||||
next.click()
|
||||
} else {
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
|
||||
next.focus()
|
||||
next.click()
|
||||
} else if (next) {
|
||||
next.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Up/Down arrows: exit field and navigate to element above/below
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
// Up/Down: exit field, navigate spatially
|
||||
e.preventDefault()
|
||||
;(target as HTMLElement).blur()
|
||||
// Fall through to arrow key handling below
|
||||
} else if (e.key !== 'Escape') {
|
||||
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
|
||||
const candidates = getFocusableElements(containerRef?.value ?? document).filter(el => el !== target)
|
||||
const nearest = findNearestInDirection(target, candidates, dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
return
|
||||
}
|
||||
// Left/Right: stay in field (cursor movement). Escape: handled below.
|
||||
if (e.key !== 'Escape') return
|
||||
}
|
||||
|
||||
const root = containerRef?.value ?? document
|
||||
const focusable = getFocusableElements(root)
|
||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// --- ESCAPE ---
|
||||
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
|
||||
if (e.key === 'Escape') {
|
||||
if (useAppLauncherStore().isOpen) {
|
||||
useAppLauncherStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useCLIStore().isOpen) {
|
||||
useCLIStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
|
||||
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
|
||||
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
|
||||
|
||||
// Inside container inner controls → exit to container
|
||||
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' })
|
||||
focusEl(container, 'back')
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
||||
if (isDetailPage) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
// On a container or anywhere in main → go to sidebar
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const sidebar = getSidebarElements()
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? sidebar[0]
|
||||
if (target) {
|
||||
rememberFocus('main', activeEl)
|
||||
focusEl(target, '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
|
||||
}
|
||||
|
||||
// Don't navigate back from top-level pages — it leads to a dead end
|
||||
const topLevel = ['/', '/dashboard', '/login', '/kiosk']
|
||||
if (!topLevel.some(p => route.path === p || route.path.startsWith('/dashboard'))) {
|
||||
// Detail pages: go back
|
||||
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
@@ -249,288 +288,236 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
return
|
||||
}
|
||||
|
||||
// --- ENTER ---
|
||||
// ── ENTER ───────────────────────────────────────────────
|
||||
if (e.key === 'Enter') {
|
||||
if (currentIndex >= 0 && focusable[currentIndex]) {
|
||||
const el = focusable[currentIndex] as HTMLElement
|
||||
|
||||
if (el.hasAttribute('data-controller-container')) {
|
||||
// Marketplace: Enter = install (click install button)
|
||||
if (el.hasAttribute('data-controller-install')) {
|
||||
const installBtn = el.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||
if (installBtn) {
|
||||
playNavSound('action')
|
||||
installBtn.click()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
// My Apps: Enter = launch (click Launch button when app is runnable)
|
||||
if (el.hasAttribute('data-controller-launch')) {
|
||||
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
||||
if (launchBtn) {
|
||||
playNavSound('action')
|
||||
launchBtn.click()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
// My Apps, etc: Enter = focus first inner control
|
||||
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()
|
||||
|
||||
if (isContainer(activeEl)) {
|
||||
// Container has a primary action link (the > chevron)?
|
||||
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
|
||||
if (activeEl.hasAttribute('data-controller-install')) {
|
||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||
if (btn) { playNavSound('action'); btn.click(); return }
|
||||
}
|
||||
if (activeEl.hasAttribute('data-controller-launch')) {
|
||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
||||
if (btn) { playNavSound('action'); btn.click(); return }
|
||||
}
|
||||
// Default: click the primary link to navigate to that section
|
||||
if (primaryLink) {
|
||||
playNavSound('action')
|
||||
primaryLink.click()
|
||||
return
|
||||
}
|
||||
// No primary link — drill into inner controls
|
||||
const inner = getInnerFocusables(activeEl)
|
||||
if (inner[0]) {
|
||||
focusEl(inner[0], 'action')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Regular element: click it
|
||||
if (activeEl) {
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- ARROWS ---
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
// ── ARROW KEYS ──────────────────────────────────────────
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
||||
e.preventDefault()
|
||||
|
||||
// Tab roving: Left/Right on role="tab" switches to sibling tab and activates it
|
||||
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && activeEl?.getAttribute('role') === 'tab') {
|
||||
const tablist = activeEl.closest('[role="tablist"]') ?? activeEl.parentElement
|
||||
if (tablist) {
|
||||
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])'))
|
||||
const idx = tabs.indexOf(activeEl)
|
||||
if (idx >= 0) {
|
||||
const nextIdx = e.key === 'ArrowRight'
|
||||
? (idx + 1) % tabs.length
|
||||
: (idx - 1 + tabs.length) % tabs.length
|
||||
const next = tabs[nextIdx]
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.click()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// Mark controller as active
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
|
||||
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' as const
|
||||
: e.key === 'ArrowRight' ? 'right' as const
|
||||
: e.key === 'ArrowUp' ? 'up' as const
|
||||
: 'down' as const
|
||||
|
||||
// ── SIDEBAR ─────────────────────────────────────────────
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const items = getSidebarElements()
|
||||
const idx = items.indexOf(activeEl)
|
||||
|
||||
if (dir === 'up' || dir === 'down') {
|
||||
// Linear wrap
|
||||
if (idx < 0) return
|
||||
const nextIdx = dir === 'down'
|
||||
? (idx >= items.length - 1 ? 0 : idx + 1)
|
||||
: (idx <= 0 ? items.length - 1 : idx - 1)
|
||||
const next = items[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
focusEl(next)
|
||||
// Auto-navigate sidebar links
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute('href')
|
||||
if (href?.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const mainEls = getElementsInZone('main')
|
||||
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
||||
|
||||
// Right: from sidebar → main
|
||||
// - On Home: go to My Apps container
|
||||
// - On Apps/Marketplace: go to first app container
|
||||
// - On Cloud: go to first folder (Pictures)
|
||||
// - On Network (server): go to Services container
|
||||
// - On Web5: go to Networking Profits container
|
||||
// - On Settings: go to Change Password container
|
||||
// - Otherwise: go to top right (App Switcher)
|
||||
const mainZone = document.querySelector('[data-controller-zone="main"]')
|
||||
const isHome = /^\/dashboard(\/)?$/.test(route.path)
|
||||
const isAppsOrMarketplace = /^\/dashboard\/(apps|marketplace)(\/|$)/.test(route.path)
|
||||
const isCloud = /^\/dashboard\/cloud(\/|$)/.test(route.path)
|
||||
const isNetwork = /^\/dashboard\/server(\/|$)/.test(route.path)
|
||||
const isWeb5 = /^\/dashboard\/web5(\/|$)/.test(route.path)
|
||||
const isSettings = /^\/dashboard\/settings(\/|$)/.test(route.path)
|
||||
const firstAppContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
|
||||
const topRightEntry = mainZone?.querySelector<HTMLElement>('[data-controller-main-entry]')
|
||||
const firstFocusableInTopRight = topRightEntry ? getFocusableElements(topRightEntry)[0] : null
|
||||
const firstMain = ((isHome || isAppsOrMarketplace || isCloud || isNetwork || isWeb5 || isSettings) && firstAppContainer)
|
||||
? firstAppContainer
|
||||
: (firstFocusableInTopRight ?? 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 (dir === 'right') {
|
||||
// Jump to first container in main
|
||||
rememberFocus('sidebar', activeEl)
|
||||
const remembered = recallFocus('main')
|
||||
const containers = getContainers()
|
||||
const target = remembered ?? containers[0]
|
||||
if (target) focusEl(target)
|
||||
return
|
||||
}
|
||||
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// Left from sidebar: does nothing
|
||||
return
|
||||
}
|
||||
|
||||
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
|
||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
||||
// ── INSIDE CONTAINER (inner controls) ───────────────────
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
const inner = getInnerFocusables(container)
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) focusEl(next)
|
||||
// Can't leave container via arrows — must use Escape
|
||||
return
|
||||
}
|
||||
|
||||
// ── NAV BAR [N] — secondary controls above the grid ────
|
||||
if (isNavBarItem(activeEl)) {
|
||||
const navItems = getNavBarItems()
|
||||
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
// Spatial nav between nav bar items
|
||||
const next = findNearestInDirection(activeEl, navItems, dir)
|
||||
if (next) { focusEl(next); return }
|
||||
// Left from leftmost nav item → sidebar
|
||||
if (dir === 'left') {
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeNavTab ?? sidebarEls[0]
|
||||
if (target) {
|
||||
playNavSound('move')
|
||||
target.focus()
|
||||
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? getSidebarElements()[0]
|
||||
if (target) focusEl(target)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if (dir === 'down') {
|
||||
// Down from nav bar → first container
|
||||
const containers = getContainers()
|
||||
const nearest = findNearestInDirection(activeEl, containers, 'down')
|
||||
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
|
||||
// Fallback: just focus first container
|
||||
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]) }
|
||||
return
|
||||
}
|
||||
|
||||
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home)
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const idx = sidebarEls.indexOf(activeEl)
|
||||
if (idx >= 0) {
|
||||
const isDown = e.key === 'ArrowDown'
|
||||
let nextIdx: number
|
||||
if (isDown) {
|
||||
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
|
||||
} else {
|
||||
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// Up from nav bar → nothing
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: linear navigation
|
||||
let nextIndex = currentIndex
|
||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
||||
if (focusable.length === 0) return
|
||||
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const containers = getContainers()
|
||||
|
||||
if (currentIndex < 0) {
|
||||
nextIndex = isForward ? 0 : focusable.length - 1
|
||||
} else {
|
||||
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
|
||||
if (nextIndex < 0) nextIndex = focusable.length - 1
|
||||
if (nextIndex >= focusable.length) nextIndex = 0
|
||||
}
|
||||
|
||||
const next = focusable[nextIndex]
|
||||
// Try spatial nav to another container
|
||||
const next = findNearestInDirection(activeEl, containers, dir)
|
||||
if (next) {
|
||||
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()
|
||||
rememberFocus('main', next)
|
||||
focusEl(next)
|
||||
return
|
||||
}
|
||||
|
||||
// Up from top-row container → nav bar (if exists)
|
||||
if (dir === 'up') {
|
||||
const navItems = getNavBarItems()
|
||||
if (navItems.length) {
|
||||
const nearest = findNearestInDirection(activeEl, navItems, 'up')
|
||||
if (nearest) { focusEl(nearest); return }
|
||||
// Fallback: first nav bar item
|
||||
const first = navItems[0]
|
||||
if (first) focusEl(first)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Left from leftmost container → sidebar
|
||||
if (dir === 'left') {
|
||||
rememberFocus('main', activeEl)
|
||||
const remembered = recallFocus('sidebar')
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = remembered ?? activeTab ?? getSidebarElements()[0]
|
||||
if (target) focusEl(target)
|
||||
return
|
||||
}
|
||||
|
||||
// At grid edges (down/right with no target): do nothing
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleGamepadInput() {
|
||||
checkGamepads()
|
||||
}
|
||||
// ─── Gamepad Detection ──────────────────────────────────────
|
||||
|
||||
function handleGamepadConnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
|
||||
isControllerActive.value = true
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}
|
||||
|
||||
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
|
||||
function getScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
|
||||
let p = el?.parentElement
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
const oy = style.overflowY
|
||||
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p
|
||||
p = p.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
// ─── Scroll Support ────────────────────────────────────────
|
||||
|
||||
/** Ensure wheel scrolls the scrollable area containing the focused element */
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return
|
||||
const scrollable = getScrollableAncestor(active)
|
||||
if (!scrollable) return
|
||||
if (e.deltaY !== 0) {
|
||||
scrollable.scrollTop += e.deltaY
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
|
||||
scrollable.scrollLeft += e.deltaX
|
||||
e.preventDefault()
|
||||
let p = active.parentElement
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
|
||||
return
|
||||
}
|
||||
p = p.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-focus first main container on route change (game-style: always have something selected) */
|
||||
// ─── Auto-Focus on Route Change ────────────────────────────
|
||||
|
||||
function autoFocusMain() {
|
||||
// Don't steal focus from inputs or modals
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
|
||||
if (document.querySelector('[role="dialog"]')) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const mainZone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (!mainZone) return
|
||||
const firstContainer = mainZone.querySelector<HTMLElement>('[data-controller-container]')
|
||||
if (firstContainer) {
|
||||
firstContainer.focus({ preventScroll: true })
|
||||
}
|
||||
const remembered = recallFocus('main')
|
||||
if (remembered) { remembered.focus({ preventScroll: true }); return }
|
||||
const containers = getContainers()
|
||||
if (containers[0]) containers[0].focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => route.path, () => {
|
||||
// Small delay to let Vue render the new route's DOM
|
||||
zoneFocusMemory.delete('main')
|
||||
setTimeout(autoFocusMain, 150)
|
||||
})
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
checkGamepads()
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
pollIntervalId = setInterval(handleGamepadInput, 500)
|
||||
// Initial auto-focus after mount
|
||||
pollIntervalId = setInterval(() => checkGamepads(), 500)
|
||||
setTimeout(autoFocusMain, 300)
|
||||
})
|
||||
|
||||
@@ -543,8 +530,5 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
})
|
||||
|
||||
return {
|
||||
isControllerActive,
|
||||
gamepadCount,
|
||||
}
|
||||
return { isControllerActive, gamepadCount }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
.skip-to-content:focus {
|
||||
.skip-to-content:focus,
|
||||
.skip-to-content:focus-visible {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
@@ -44,14 +45,32 @@
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
backdrop-filter: blur(12px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Controller / keyboard navigation - orange border (Archipelago brand) */
|
||||
/* Controller / keyboard navigation — only for elements without their own focus styles.
|
||||
Elements with existing hover/active styles (glass-button, sidebar-nav-item, etc.) keep theirs. */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
border-color: rgba(251, 146, 60, 0.8) !important;
|
||||
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(251, 146, 60, 0.4),
|
||||
0 0 12px rgba(251, 146, 60, 0.2),
|
||||
0 0 24px rgba(251, 146, 60, 0.08);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Elements with existing styles: suppress the global glow, let their own styles handle it */
|
||||
.glass-button:focus-visible,
|
||||
.glass-card:focus-visible,
|
||||
.sidebar-nav-item:focus-visible,
|
||||
.path-action-button:focus-visible,
|
||||
.path-option-card:focus-visible,
|
||||
.mode-switcher-btn:focus-visible,
|
||||
.kiosk-app-tile:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
|
||||
@@ -99,13 +118,15 @@ input[type="radio"]:active + * {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Containers get subtle grow + orange glow when focused (gamepad selection) */
|
||||
/* Containers: console-style focus — subtle lift + ambient glow through glass */
|
||||
[data-controller-container]:focus-visible {
|
||||
transform: scale(1.02);
|
||||
outline: none;
|
||||
transform: scale(1.01) translateZ(0);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(251, 146, 60, 0.7),
|
||||
0 0 24px rgba(251, 146, 60, 0.2),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.03);
|
||||
0 0 0 1px rgba(251, 146, 60, 0.35),
|
||||
0 4px 20px rgba(251, 146, 60, 0.12),
|
||||
0 0 40px rgba(251, 146, 60, 0.06),
|
||||
inset 0 1px 0 rgba(251, 146, 60, 0.1);
|
||||
}
|
||||
|
||||
/* Global glassmorphism utilities */
|
||||
@@ -977,11 +998,9 @@ input[type="radio"]:active + * {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.sidebar-nav-item:focus-visible {
|
||||
transform: scale(1.02) !important;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(251, 146, 60, 0.7),
|
||||
0 0 24px rgba(251, 146, 60, 0.2),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
|
||||
outline: none !important;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,7 +1322,8 @@ html:has(body.video-background-active)::before {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.cloud-file-item:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
|
||||
.cloud-file-item-thumb {
|
||||
@@ -1481,7 +1501,8 @@ html:has(body.video-background-active)::before {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.cloud-grid-card:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
|
||||
.cloud-grid-card-cover {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Skip to main content link for keyboard users -->
|
||||
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
|
||||
<!-- Skip-to-content handled by controller nav sidebar→main transition -->
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||
@@ -126,7 +126,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
@@ -140,8 +139,6 @@ import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -216,12 +216,12 @@
|
||||
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Modals -->
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
<!-- Wallet Modals -->
|
||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -346,7 +346,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<!-- Device Status -->
|
||||
<div class="glass-card mesh-status-card">
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
|
||||
<div class="mesh-status-header">
|
||||
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
||||
<h2 class="mesh-section-title">Device</h2>
|
||||
@@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
</div>
|
||||
|
||||
<!-- Peers list -->
|
||||
<div class="glass-card mesh-peers-card">
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
|
||||
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
|
||||
|
||||
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
@@ -512,7 +512,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
|
||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||
<div class="mesh-chat-empty-icon">📡</div>
|
||||
<p>Select a peer or channel to chat</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p>
|
||||
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
|
||||
<p class="text-xs text-white/40">
|
||||
@@ -8,22 +8,22 @@
|
||||
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
|
||||
</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
|
||||
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
|
||||
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
|
||||
@@ -114,7 +114,7 @@ init()
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card (editable) -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
|
||||
@@ -48,7 +48,7 @@ async function performFactoryReset() {
|
||||
|
||||
<template>
|
||||
<!-- Network Diagnostics Link -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div data-controller-container tabindex="0" class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
|
||||
@@ -64,7 +64,7 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
|
||||
<!-- Reboot Section -->
|
||||
<div class="path-option-card px-6 py-6 mt-6">
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
|
||||
@@ -109,7 +109,7 @@ async function performFactoryReset() {
|
||||
</Teleport>
|
||||
|
||||
<!-- Factory Reset Section -->
|
||||
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
|
||||
|
||||
@@ -128,7 +128,7 @@ loadTotpStatus()
|
||||
|
||||
<template>
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div class="mb-6">
|
||||
<div data-controller-container tabindex="0" class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user